Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f392908

Browse files
committedAug 24, 2020
Merges with 'github/develop' and applies spotless
Change-Id: I734022b261df56d546749c2c87051c97096a6970
2 parents 8a63e3d + 19b5e1c commit f392908

File tree

19 files changed

+935
-561
lines changed

19 files changed

+935
-561
lines changed
 

‎JetNews/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ dependencies {
7474
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
7575

7676
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.0-alpha06"
77+
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha06"
7778

7879
androidTestImplementation 'junit:junit:4.13'
7980
androidTestImplementation 'androidx.test:rules:1.2.0'

‎JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,16 @@ import androidx.compose.runtime.remember
2121
import androidx.lifecycle.SavedStateHandle
2222
import androidx.ui.test.ComposeTestRule
2323
import com.example.jetnews.ui.JetnewsApp
24-
import com.example.jetnews.ui.JetnewsStatus
2524
import com.example.jetnews.ui.NavigationViewModel
2625

2726
/**
2827
* Launches the app from a test context
2928
*/
3029
fun ComposeTestRule.launchJetNewsApp(context: Context) {
3130
setContent {
32-
JetnewsStatus.resetState()
3331
JetnewsApp(
3432
TestAppContainer(context),
3533
remember { NavigationViewModel(SavedStateHandle()) }
3634
)
3735
}
3836
}
39-
40-
/**
41-
* Resets the state of the app. Needs to be executed in Compose code (within a frame)
42-
*/
43-
fun JetnewsStatus.resetState() {
44-
favorites.clear()
45-
selectedTopics.clear()
46-
}

‎JetNews/app/src/main/java/com/example/jetnews/data/AppContainerImpl.kt

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,10 @@
1717
package com.example.jetnews.data
1818

1919
import android.content.Context
20-
import android.os.Handler
21-
import android.os.Looper
2220
import com.example.jetnews.data.interests.InterestsRepository
2321
import com.example.jetnews.data.interests.impl.FakeInterestsRepository
2422
import com.example.jetnews.data.posts.PostsRepository
2523
import com.example.jetnews.data.posts.impl.FakePostsRepository
26-
import java.util.concurrent.ExecutorService
27-
import java.util.concurrent.Executors
2824

2925
/**
3026
* Dependency Injection container at the application level.
@@ -41,18 +37,8 @@ interface AppContainer {
4137
*/
4238
class AppContainerImpl(private val applicationContext: Context) : AppContainer {
4339

44-
private val executorService: ExecutorService by lazy {
45-
Executors.newFixedThreadPool(4)
46-
}
47-
48-
private val mainThreadHandler: Handler by lazy {
49-
Handler(Looper.getMainLooper())
50-
}
51-
5240
override val postsRepository: PostsRepository by lazy {
5341
FakePostsRepository(
54-
executorService = executorService,
55-
resultThreadHandler = mainThreadHandler,
5642
resources = applicationContext.resources
5743
)
5844
}

‎JetNews/app/src/main/java/com/example/jetnews/data/interests/InterestsRepository.kt

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
package com.example.jetnews.data.interests
1818

1919
import com.example.jetnews.data.Result
20+
import kotlinx.coroutines.flow.Flow
21+
22+
typealias TopicsMap = Map<String, List<String>>
2023

2124
/**
2225
* Interface to the Interests data layer.
@@ -26,15 +29,47 @@ interface InterestsRepository {
2629
/**
2730
* Get relevant topics to the user.
2831
*/
29-
fun getTopics(callback: (Result<Map<String, List<String>>>) -> Unit)
32+
suspend fun getTopics(): Result<TopicsMap>
3033

3134
/**
3235
* Get list of people.
3336
*/
34-
fun getPeople(callback: (Result<List<String>>) -> Unit)
37+
suspend fun getPeople(): Result<List<String>>
3538

3639
/**
3740
* Get list of publications.
3841
*/
39-
fun getPublications(callback: (Result<List<String>>) -> Unit)
42+
suspend fun getPublications(): Result<List<String>>
43+
44+
/**
45+
* Toggle between selected and unselected
46+
*/
47+
suspend fun toggleTopicSelection(topic: TopicSelection)
48+
49+
/**
50+
* Toggle between selected and unselected
51+
*/
52+
suspend fun togglePersonSelected(person: String)
53+
54+
/**
55+
* Toggle between selected and unselected
56+
*/
57+
suspend fun togglePublicationSelected(publication: String)
58+
59+
/**
60+
* Currently selected topics
61+
*/
62+
fun observeTopicsSelected(): Flow<Set<TopicSelection>>
63+
64+
/**
65+
* Currently selected people
66+
*/
67+
fun observePeopleSelected(): Flow<Set<String>>
68+
69+
/**
70+
* Currently selected publications
71+
*/
72+
fun observePublicationSelected(): Flow<Set<String>>
4073
}
74+
75+
data class TopicSelection(val section: String, val topic: String)

‎JetNews/app/src/main/java/com/example/jetnews/data/interests/impl/FakeInterestsRepository.kt

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,20 @@ package com.example.jetnews.data.interests.impl
1818

1919
import com.example.jetnews.data.Result
2020
import com.example.jetnews.data.interests.InterestsRepository
21+
import com.example.jetnews.data.interests.TopicSelection
22+
import com.example.jetnews.data.interests.TopicsMap
23+
import com.example.jetnews.utils.addOrRemove
24+
import kotlinx.coroutines.ExperimentalCoroutinesApi
25+
import kotlinx.coroutines.flow.Flow
26+
import kotlinx.coroutines.flow.MutableStateFlow
27+
import kotlinx.coroutines.sync.Mutex
28+
import kotlinx.coroutines.sync.withLock
2129

2230
/**
2331
* Implementation of InterestRepository that returns a hardcoded list of
2432
* topics, people and publications synchronously.
2533
*/
34+
@OptIn(ExperimentalCoroutinesApi::class)
2635
class FakeInterestsRepository : InterestsRepository {
2736

2837
private val topics by lazy {
@@ -61,15 +70,53 @@ class FakeInterestsRepository : InterestsRepository {
6170
)
6271
}
6372

64-
override fun getTopics(callback: (Result<Map<String, List<String>>>) -> Unit) {
65-
callback(Result.Success(topics))
73+
// for now, keep the selections in memory
74+
private val selectedTopics = MutableStateFlow(setOf<TopicSelection>())
75+
private val selectedPeople = MutableStateFlow(setOf<String>())
76+
private val selectedPublications = MutableStateFlow(setOf<String>())
77+
78+
// Used to make suspend functions that read and update state safe to call from any thread
79+
private val mutex = Mutex()
80+
81+
override suspend fun getTopics(): Result<TopicsMap> {
82+
return Result.Success(topics)
83+
}
84+
85+
override suspend fun getPeople(): Result<List<String>> {
86+
return Result.Success(people)
87+
}
88+
89+
override suspend fun getPublications(): Result<List<String>> {
90+
return Result.Success(publications)
6691
}
6792

68-
override fun getPeople(callback: (Result<List<String>>) -> Unit) {
69-
callback(Result.Success(people))
93+
override suspend fun toggleTopicSelection(topic: TopicSelection) {
94+
mutex.withLock {
95+
val set = selectedTopics.value.toMutableSet()
96+
set.addOrRemove(topic)
97+
selectedTopics.value = set
98+
}
7099
}
71100

72-
override fun getPublications(callback: (Result<List<String>>) -> Unit) {
73-
callback(Result.Success(publications))
101+
override suspend fun togglePersonSelected(person: String) {
102+
mutex.withLock {
103+
val set = selectedPeople.value.toMutableSet()
104+
set.addOrRemove(person)
105+
selectedPeople.value = set
106+
}
74107
}
108+
109+
override suspend fun togglePublicationSelected(publication: String) {
110+
mutex.withLock {
111+
val set = selectedPublications.value.toMutableSet()
112+
set.addOrRemove(publication)
113+
selectedPublications.value = set
114+
}
115+
}
116+
117+
override fun observeTopicsSelected(): Flow<Set<TopicSelection>> = selectedTopics
118+
119+
override fun observePeopleSelected(): Flow<Set<String>> = selectedPeople
120+
121+
override fun observePublicationSelected(): Flow<Set<String>> = selectedPublications
75122
}

‎JetNews/app/src/main/java/com/example/jetnews/data/posts/PostsRepository.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.example.jetnews.data.posts
1818

1919
import com.example.jetnews.data.Result
2020
import com.example.jetnews.model.Post
21+
import kotlinx.coroutines.flow.Flow
2122

2223
/**
2324
* Interface to the Posts data layer.
@@ -27,10 +28,20 @@ interface PostsRepository {
2728
/**
2829
* Get a specific JetNews post.
2930
*/
30-
fun getPost(postId: String, callback: (Result<Post?>) -> Unit)
31+
suspend fun getPost(postId: String): Result<Post>
3132

3233
/**
3334
* Get JetNews posts.
3435
*/
35-
fun getPosts(callback: (Result<List<Post>>) -> Unit)
36+
suspend fun getPosts(): Result<List<Post>>
37+
38+
/**
39+
* Observe the current favorites
40+
*/
41+
fun observeFavorites(): Flow<Set<String>>
42+
43+
/**
44+
* Toggle a postId to be a favorite or not.
45+
*/
46+
suspend fun toggleFavorite(postId: String)
3647
}

‎JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/BlockingFakePostsRepository.kt

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ import androidx.compose.ui.graphics.imageFromResource
2121
import com.example.jetnews.data.Result
2222
import com.example.jetnews.data.posts.PostsRepository
2323
import com.example.jetnews.model.Post
24+
import com.example.jetnews.utils.addOrRemove
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.flow.Flow
28+
import kotlinx.coroutines.flow.MutableStateFlow
29+
import kotlinx.coroutines.withContext
2430

2531
/**
2632
* Implementation of PostsRepository that returns a hardcoded list of
2733
* posts with resources synchronously.
2834
*/
35+
@OptIn(ExperimentalCoroutinesApi::class)
2936
class BlockingFakePostsRepository(private val context: Context) : PostsRepository {
3037

3138
private val postsWithResources: List<Post> by lazy {
@@ -37,11 +44,29 @@ class BlockingFakePostsRepository(private val context: Context) : PostsRepositor
3744
}
3845
}
3946

40-
override fun getPost(postId: String, callback: (Result<Post?>) -> Unit) {
41-
callback(Result.Success(postsWithResources.find { it.id == postId }))
47+
// for now, keep the favorites in memory
48+
private val favorites = MutableStateFlow<Set<String>>(setOf())
49+
50+
override suspend fun getPost(postId: String): Result<Post> {
51+
return withContext(Dispatchers.IO) {
52+
val post = postsWithResources.find { it.id == postId }
53+
if (post == null) {
54+
Result.Error(IllegalArgumentException("Unable to find post"))
55+
} else {
56+
Result.Success(post)
57+
}
58+
}
4259
}
4360

44-
override fun getPosts(callback: (Result<List<Post>>) -> Unit) {
45-
callback(Result.Success(postsWithResources))
61+
override suspend fun getPosts(): Result<List<Post>> {
62+
return Result.Success(postsWithResources)
63+
}
64+
65+
override fun observeFavorites(): Flow<Set<String>> = favorites
66+
67+
override suspend fun toggleFavorite(postId: String) {
68+
val set = favorites.value.toMutableSet()
69+
set.addOrRemove(postId)
70+
favorites.value = set
4671
}
4772
}

‎JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,26 @@
1717
package com.example.jetnews.data.posts.impl
1818

1919
import android.content.res.Resources
20-
import android.os.Handler
2120
import androidx.compose.ui.graphics.imageFromResource
2221
import com.example.jetnews.data.Result
2322
import com.example.jetnews.data.posts.PostsRepository
2423
import com.example.jetnews.model.Post
25-
import java.util.concurrent.ExecutorService
26-
import kotlin.random.Random
24+
import com.example.jetnews.utils.addOrRemove
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.delay
28+
import kotlinx.coroutines.flow.Flow
29+
import kotlinx.coroutines.flow.MutableStateFlow
30+
import kotlinx.coroutines.sync.Mutex
31+
import kotlinx.coroutines.sync.withLock
32+
import kotlinx.coroutines.withContext
2733

2834
/**
2935
* Implementation of PostsRepository that returns a hardcoded list of
3036
* posts with resources after some delay in a background thread.
31-
* 1/3 of the times will throw an error.
32-
*
33-
* The result is posted to the resultThreadHandler passed as a parameter.
3437
*/
38+
@OptIn(ExperimentalCoroutinesApi::class)
3539
class FakePostsRepository(
36-
private val executorService: ExecutorService,
37-
private val resultThreadHandler: Handler,
3840
private val resources: Resources
3941
) : PostsRepository {
4042

@@ -53,56 +55,52 @@ class FakePostsRepository(
5355
}
5456
}
5557

56-
override fun getPost(postId: String, callback: (Result<Post?>) -> Unit) {
57-
executeInBackground(callback) {
58-
resultThreadHandler.post {
59-
callback(
60-
Result.Success(
61-
postsWithResources.find { it.id == postId }
62-
)
63-
)
58+
// for now, store these in memory
59+
private val favorites = MutableStateFlow<Set<String>>(setOf())
60+
61+
// Used to make suspend functions that read and update state safe to call from any thread
62+
private val mutex = Mutex()
63+
64+
override suspend fun getPost(postId: String): Result<Post> {
65+
return withContext(Dispatchers.IO) {
66+
val post = postsWithResources.find { it.id == postId }
67+
if (post == null) {
68+
Result.Error(IllegalArgumentException("Post not found"))
69+
} else {
70+
Result.Success(post)
6471
}
6572
}
6673
}
6774

68-
override fun getPosts(callback: (Result<List<Post>>) -> Unit) {
69-
executeInBackground(callback) {
70-
simulateNetworkRequest()
71-
Thread.sleep(1500L)
75+
override suspend fun getPosts(): Result<List<Post>> {
76+
return withContext(Dispatchers.IO) {
77+
delay(800) // pretend we're on a slow network
7278
if (shouldRandomlyFail()) {
73-
throw IllegalStateException()
79+
Result.Error(IllegalStateException())
80+
} else {
81+
Result.Success(postsWithResources)
7482
}
75-
resultThreadHandler.post { callback(Result.Success(postsWithResources)) }
7683
}
7784
}
7885

79-
/**
80-
* Executes a block of code in the past and returns an error in the [callback]
81-
* if [block] throws an exception.
82-
*/
83-
private fun executeInBackground(callback: (Result<Nothing>) -> Unit, block: () -> Unit) {
84-
executorService.execute {
85-
try {
86-
block()
87-
} catch (e: Exception) {
88-
resultThreadHandler.post { callback(Result.Error(e)) }
89-
}
90-
}
91-
}
86+
override fun observeFavorites(): Flow<Set<String>> = favorites
9287

93-
/**
94-
* Simulates network request
95-
*/
96-
private var networkRequestDone = false
97-
private fun simulateNetworkRequest() {
98-
if (!networkRequestDone) {
99-
Thread.sleep(2000L)
100-
networkRequestDone = true
88+
override suspend fun toggleFavorite(postId: String) {
89+
mutex.withLock {
90+
val set = favorites.value.toMutableSet()
91+
set.addOrRemove(postId)
92+
favorites.value = set.toSet()
10193
}
10294
}
10395

96+
// used to drive "random" failure in a predictable pattern, making the first request always
97+
// succeed
98+
private var requestCount = 0
99+
104100
/**
105-
* 1/3 requests should fail loading
101+
* Randomly fail some loads to simulate a real network.
102+
*
103+
* This will fail deterministically every 5 requests
106104
*/
107-
private fun shouldRandomlyFail(): Boolean = Random.nextFloat() < 0.33f
105+
private fun shouldRandomlyFail(): Boolean = ++requestCount % 5 == 0
108106
}

‎JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/PreviewPostsRepository.kt

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,15 @@ fun SwipeToRefreshLayout(
4343
) {
4444
val refreshDistance = with(DensityAmbient.current) { RefreshDistance.toPx() }
4545
val state = rememberSwipeableState(refreshingState)
46+
// TODO (https://issuetracker.google.com/issues/164113834): This state->event trampoline is a
47+
// workaround for a bug in the SwipableState API. It should be replaced with a correct solution
48+
// when that bug closes.
4649
onCommit(refreshingState) {
4750
state.animateTo(refreshingState)
4851
}
49-
// When complete the swipe-to-refresh, kick off the action
52+
// TODO (https://issuetracker.google.com/issues/164113834): Hoist state changes when bug is
53+
// fixed and do this logic in the ViewModel. Currently, state.value is a duplicated source of
54+
// truth of refreshingState
5055
onCommit(state.value) {
5156
if (state.value) {
5257
onRefresh()
@@ -56,6 +61,7 @@ fun SwipeToRefreshLayout(
5661
Stack(
5762
modifier = Modifier.swipeable(
5863
state = state,
64+
enabled = !state.value,
5965
anchors = mapOf(
6066
-refreshDistance to false,
6167
refreshDistance to true

‎JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ import androidx.compose.material.icons.filled.ArrowBack
3838
import androidx.compose.material.icons.filled.FavoriteBorder
3939
import androidx.compose.material.icons.filled.Share
4040
import androidx.compose.runtime.Composable
41+
import androidx.compose.runtime.collectAsState
4142
import androidx.compose.runtime.getValue
42-
import androidx.compose.runtime.mutableStateOf
43-
import androidx.compose.runtime.remember
43+
import androidx.compose.runtime.rememberCoroutineScope
44+
import androidx.compose.runtime.savedinstancestate.savedInstanceState
4445
import androidx.compose.runtime.setValue
4546
import androidx.compose.ui.Alignment
4647
import androidx.compose.ui.Modifier
@@ -49,30 +50,74 @@ import androidx.compose.ui.res.vectorResource
4950
import androidx.compose.ui.unit.dp
5051
import androidx.ui.tooling.preview.Preview
5152
import com.example.jetnews.R
53+
import com.example.jetnews.data.Result
5254
import com.example.jetnews.data.posts.PostsRepository
5355
import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository
5456
import com.example.jetnews.data.posts.impl.post3
55-
import com.example.jetnews.data.successOr
5657
import com.example.jetnews.model.Post
5758
import com.example.jetnews.ui.ThemedPreview
58-
import com.example.jetnews.ui.effect.fetchPost
5959
import com.example.jetnews.ui.home.BookmarkButton
60-
import com.example.jetnews.ui.home.isFavorite
61-
import com.example.jetnews.ui.home.toggleBookmark
62-
import com.example.jetnews.ui.state.UiState
60+
import com.example.jetnews.utils.launchUiStateProducer
61+
import kotlinx.coroutines.launch
62+
import kotlinx.coroutines.runBlocking
6363

64+
/**
65+
* Stateful Article Screen that manages state using [launchUiStateProducer]
66+
*
67+
* @param postId (state) the post to show
68+
* @param postsRepository data source for this screen
69+
* @param onBack (event) request back navigation
70+
*/
71+
@Suppress("DEPRECATION") // allow ViewModelLifecycleScope call
6472
@Composable
65-
fun ArticleScreen(postId: String, postsRepository: PostsRepository, onBack: () -> Unit) {
66-
val postsState = fetchPost(postId, postsRepository)
67-
if (postsState is UiState.Success<Post>) {
68-
ArticleScreen(postsState.data, onBack)
73+
fun ArticleScreen(
74+
postId: String,
75+
postsRepository: PostsRepository,
76+
onBack: () -> Unit
77+
) {
78+
val (post) = launchUiStateProducer(postsRepository, postId) {
79+
getPost(postId)
6980
}
81+
// TODO: handle errors when the repository is capable of creating them
82+
val postData = post.value.data ?: return
83+
84+
// [collectAsState] will automatically collect a Flow<T> and return a State<T> object that
85+
// updates whenever the Flow emits a value. Collection is cancelled when [collectAsState] is
86+
// removed from the composition tree.
87+
val favorites by postsRepository.observeFavorites().collectAsState(setOf())
88+
val isFavorite = favorites.contains(postId)
89+
90+
// Returns a [CoroutineScope] that is scoped to the lifecycle of [ArticleScreen]. When this
91+
// screen is removed from composition, the scope will be cancelled.
92+
val coroutineScope = rememberCoroutineScope()
93+
94+
ArticleScreen(
95+
post = postData,
96+
onBack = onBack,
97+
isFavorite = isFavorite,
98+
onToggleFavorite = {
99+
coroutineScope.launch { postsRepository.toggleFavorite(postId) }
100+
}
101+
)
70102
}
71103

104+
/**
105+
* Stateless Article Screen that displays a single post.
106+
*
107+
* @param post (state) item to display
108+
* @param onBack (event) request navigate back
109+
* @param isFavorite (state) is this item currently a favorite
110+
* @param onToggleFavorite (event) request that this post toggle it's favorite state
111+
*/
72112
@Composable
73-
private fun ArticleScreen(post: Post, onBack: () -> Unit) {
113+
fun ArticleScreen(
114+
post: Post,
115+
onBack: () -> Unit,
116+
isFavorite: Boolean,
117+
onToggleFavorite: () -> Unit
118+
) {
74119

75-
var showDialog by remember { mutableStateOf(false) }
120+
var showDialog by savedInstanceState { false }
76121
if (showDialog) {
77122
FunctionalityNotAvailablePopup { showDialog = false }
78123
}
@@ -99,13 +144,31 @@ private fun ArticleScreen(post: Post, onBack: () -> Unit) {
99144
PostContent(post, modifier)
100145
},
101146
bottomBar = {
102-
BottomBar(post) { showDialog = true }
147+
BottomBar(
148+
post = post,
149+
onUnimplementedAction = { showDialog = true },
150+
isFavorite = isFavorite,
151+
onToggleFavorite = onToggleFavorite
152+
)
103153
}
104154
)
105155
}
106156

157+
/**
158+
* Bottom bar for Article screen
159+
*
160+
* @param post (state) used in share sheet to share the post
161+
* @param onUnimplementedAction (event) called when the user performs an unimplemented action
162+
* @param isFavorite (state) if this post is currently a favorite
163+
* @param onToggleFavorite (event) request this post toggle it's favorite status
164+
*/
107165
@Composable
108-
private fun BottomBar(post: Post, onUnimplementedAction: () -> Unit) {
166+
private fun BottomBar(
167+
post: Post,
168+
onUnimplementedAction: () -> Unit,
169+
isFavorite: Boolean,
170+
onToggleFavorite: () -> Unit
171+
) {
109172
Surface(elevation = 2.dp) {
110173
Row(
111174
verticalGravity = Alignment.CenterVertically,
@@ -117,8 +180,8 @@ private fun BottomBar(post: Post, onUnimplementedAction: () -> Unit) {
117180
Icon(Icons.Filled.FavoriteBorder)
118181
}
119182
BookmarkButton(
120-
isBookmarked = isFavorite(postId = post.id),
121-
onBookmark = { toggleBookmark(postId = post.id) }
183+
isBookmarked = isFavorite,
184+
onClick = onToggleFavorite
122185
)
123186
val context = ContextAmbient.current
124187
IconButton(onClick = { sharePost(post, context) }) {
@@ -132,6 +195,11 @@ private fun BottomBar(post: Post, onUnimplementedAction: () -> Unit) {
132195
}
133196
}
134197

198+
/**
199+
* Display a popup explaining functionality not available.
200+
*
201+
* @param onDismiss (event) request the popup be dismissed
202+
*/
135203
@Composable
136204
private fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) {
137205
AlertDialog(
@@ -150,6 +218,12 @@ private fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) {
150218
)
151219
}
152220

221+
/**
222+
* Show a share sheet for a post
223+
*
224+
* @param post to share
225+
* @param context Android context to show the share sheet in
226+
*/
153227
private fun sharePost(post: Post, context: Context) {
154228
val intent = Intent(Intent.ACTION_SEND).apply {
155229
type = "text/plain"
@@ -164,7 +238,7 @@ private fun sharePost(post: Post, context: Context) {
164238
fun PreviewArticle() {
165239
ThemedPreview {
166240
val post = loadFakePost(post3.id)
167-
ArticleScreen(post, {})
241+
ArticleScreen(post, {}, false, {})
168242
}
169243
}
170244

@@ -173,15 +247,15 @@ fun PreviewArticle() {
173247
fun PreviewArticleDark() {
174248
ThemedPreview(darkTheme = true) {
175249
val post = loadFakePost(post3.id)
176-
ArticleScreen(post, {})
250+
ArticleScreen(post, {}, false, {})
177251
}
178252
}
179253

180254
@Composable
181255
private fun loadFakePost(postId: String): Post {
182-
var post: Post? = null
183-
BlockingFakePostsRepository(ContextAmbient.current).getPost(postId) { result ->
184-
post = result.successOr(null)
256+
val context = ContextAmbient.current
257+
val post = runBlocking {
258+
(BlockingFakePostsRepository(context).getPost(postId) as Result.Success).data
185259
}
186-
return post!!
260+
return post
187261
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/effect/PostsEffects.kt

Lines changed: 0 additions & 61 deletions
This file was deleted.

‎JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt

Lines changed: 236 additions & 81 deletions
Large diffs are not rendered by default.

‎JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import androidx.ui.tooling.preview.Preview
4343
import com.example.jetnews.R
4444
import com.example.jetnews.data.posts.impl.post3
4545
import com.example.jetnews.model.Post
46-
import com.example.jetnews.ui.JetnewsStatus
4746
import com.example.jetnews.ui.Screen
4847
import com.example.jetnews.ui.ThemedPreview
4948

@@ -86,7 +85,12 @@ fun PostTitle(post: Post) {
8685
}
8786

8887
@Composable
89-
fun PostCardSimple(post: Post, navigateTo: (Screen) -> Unit) {
88+
fun PostCardSimple(
89+
post: Post,
90+
navigateTo: (Screen) -> Unit,
91+
isFavorite: Boolean,
92+
onToggleFavorite: () -> Unit
93+
) {
9094
Row(
9195
modifier = Modifier.clickable(onClick = { navigateTo(Screen.Article(post.id)) })
9296
.padding(16.dp)
@@ -97,8 +101,8 @@ fun PostCardSimple(post: Post, navigateTo: (Screen) -> Unit) {
97101
AuthorAndReadTime(post)
98102
}
99103
BookmarkButton(
100-
isBookmarked = isFavorite(postId = post.id),
101-
onBookmark = { toggleBookmark(postId = post.id) }
104+
isBookmarked = isFavorite,
105+
onClick = onToggleFavorite
102106
)
103107
}
104108
}
@@ -135,12 +139,12 @@ fun PostCardHistory(post: Post, navigateTo: (Screen) -> Unit) {
135139
@Composable
136140
fun BookmarkButton(
137141
isBookmarked: Boolean,
138-
onBookmark: (Boolean) -> Unit,
142+
onClick: () -> Unit,
139143
modifier: Modifier = Modifier
140144
) {
141145
IconToggleButton(
142146
checked = isBookmarked,
143-
onCheckedChange = onBookmark
147+
onCheckedChange = { onClick() }
144148
) {
145149
modifier.fillMaxSize()
146150
if (isBookmarked) {
@@ -157,24 +161,12 @@ fun BookmarkButton(
157161
}
158162
}
159163

160-
fun toggleBookmark(postId: String) {
161-
with(JetnewsStatus) {
162-
if (favorites.contains(postId)) {
163-
favorites.remove(postId)
164-
} else {
165-
favorites.add(postId)
166-
}
167-
}
168-
}
169-
170-
fun isFavorite(postId: String) = JetnewsStatus.favorites.contains(postId)
171-
172164
@Preview("Bookmark Button")
173165
@Composable
174166
fun BookmarkButtonPreview() {
175167
ThemedPreview {
176168
Surface {
177-
BookmarkButton(isBookmarked = false, onBookmark = { })
169+
BookmarkButton(isBookmarked = false, onClick = { })
178170
}
179171
}
180172
}
@@ -184,7 +176,7 @@ fun BookmarkButtonPreview() {
184176
fun BookmarkButtonBookmarkedPreview() {
185177
ThemedPreview {
186178
Surface {
187-
BookmarkButton(isBookmarked = true, onBookmark = { })
179+
BookmarkButton(isBookmarked = true, onClick = { })
188180
}
189181
}
190182
}
@@ -193,7 +185,7 @@ fun BookmarkButtonBookmarkedPreview() {
193185
@Composable
194186
fun SimplePostPreview() {
195187
ThemedPreview {
196-
PostCardSimple(post3, {})
188+
PostCardSimple(post3, {}, false, {})
197189
}
198190
}
199191

@@ -209,6 +201,6 @@ fun HistoryPostPreview() {
209201
@Composable
210202
fun SimplePostDarkPreview() {
211203
ThemedPreview(darkTheme = true) {
212-
PostCardSimple(post3, {})
204+
PostCardSimple(post3, {}, false, {})
213205
}
214206
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt

Lines changed: 223 additions & 95 deletions
Large diffs are not rendered by default.

‎JetNews/app/src/main/java/com/example/jetnews/ui/state/RefreshableUiState.kt

Lines changed: 0 additions & 95 deletions
This file was deleted.

‎JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -16,60 +16,41 @@
1616

1717
package com.example.jetnews.ui.state
1818

19-
import androidx.compose.runtime.Composable
20-
import androidx.compose.runtime.getValue
21-
import androidx.compose.runtime.mutableStateOf
22-
import androidx.compose.runtime.onActive
23-
import androidx.compose.runtime.remember
24-
import androidx.compose.runtime.setValue
25-
import androidx.compose.runtime.state
2619
import com.example.jetnews.data.Result
2720

28-
typealias RepositoryCall<T> = ((Result<T>) -> Unit) -> Unit
29-
30-
sealed class UiState<out T> {
31-
object Loading : UiState<Nothing>()
32-
data class Success<out T>(val data: T) : UiState<T>()
33-
data class Error(val exception: Exception) : UiState<Nothing>()
34-
}
35-
3621
/**
37-
* UiState factory that updates its internal state with the [com.example.jetnews.data.Result]
38-
* of a repository called as a parameter.
22+
* Immutable data class that allows for loading, data, and exception to be managed independently.
3923
*
40-
* To load asynchronous data, effects are better pattern than using @Model classes since
41-
* effects are Compose lifecycle aware.
24+
* This is useful for screens that want to show the last successful result while loading or a later
25+
* refresh has caused an error.
4226
*/
43-
@Composable
44-
fun <T> uiStateFrom(
45-
repositoryCall: RepositoryCall<T>
46-
): UiState<T> {
47-
var state: UiState<T> by remember { mutableStateOf(UiState.Loading) }
48-
49-
// Whenever this effect is used in a composable function, it'll load data from the repository
50-
// when the first composition is applied
51-
onActive {
52-
repositoryCall { result ->
53-
state = when (result) {
54-
is Result.Success -> UiState.Success(result.data)
55-
is Result.Error -> UiState.Error(result.exception)
56-
}
57-
}
58-
}
59-
60-
return state
27+
data class UiState<T>(
28+
val loading: Boolean = false,
29+
val exception: Exception? = null,
30+
val data: T? = null
31+
) {
32+
/**
33+
* True if this contains an error
34+
*/
35+
val hasError: Boolean
36+
get() = exception != null
37+
38+
/**
39+
* True if this represents a first load
40+
*/
41+
val initialLoad: Boolean
42+
get() = data == null && loading && !hasError
6143
}
6244

6345
/**
64-
* Helper function that loads data from a repository call. Only use in Previews!
46+
* Copy a UiState<T> based on a Result<T>.
47+
*
48+
* Result.Success will set all fields
49+
* Result.Error will reset loading and exception only
6550
*/
66-
@Composable
67-
fun <T> previewDataFrom(
68-
repositoryCall: RepositoryCall<T>
69-
): T {
70-
var state: T? = null
71-
repositoryCall { result ->
72-
state = (result as Result.Success).data
51+
fun <T> UiState<T>.copyWithResult(value: Result<T>): UiState<T> {
52+
return when (value) {
53+
is Result.Success -> copy(loading = false, exception = null, data = value.data)
54+
is Result.Error -> copy(loading = false, exception = value.exception)
7355
}
74-
return state!!
7556
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2020 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetnews.utils
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.State
21+
import androidx.compose.runtime.launchInComposition
22+
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.runtime.remember
24+
import com.example.jetnews.data.Result
25+
import com.example.jetnews.ui.state.UiState
26+
import com.example.jetnews.ui.state.copyWithResult
27+
import kotlinx.coroutines.ExperimentalCoroutinesApi
28+
import kotlinx.coroutines.channels.Channel
29+
30+
/**
31+
* Result object for [launchUiStateProducer].
32+
*
33+
* It is intended that you destructure this class at the call site. Here is an example usage that
34+
* calls dataSource.loadData() and then displays a UI based on the result.
35+
*
36+
* ```
37+
* val (result, onRefresh, onClearError) = launchUiStateProducer(dataSource) { loadData() }
38+
* Text(result.value)
39+
* Button(onClick = onRefresh) { Text("Refresh" }
40+
* Button(onClick = onClearError) { Text("Clear loading error") }
41+
* ```
42+
*
43+
* @param result (state) the current result of this producer in a state object
44+
* @param onRefresh (event) triggers a refresh of this producer
45+
* @param onClearError (event) clear any error values returned by this producer, useful for
46+
* transient error displays.
47+
*/
48+
data class ProducerResult<T>(
49+
val result: State<T>,
50+
val onRefresh: () -> Unit,
51+
val onClearError: () -> Unit
52+
)
53+
54+
/**
55+
* Launch a coroutine to create refreshable [UiState] from a suspending producer.
56+
*
57+
* [Producer] is any object that has a suspending method that returns [Result]. In the [block] call
58+
* the suspending method that produces a single value. The result of this call will be returned
59+
* along with an event to refresh (or call [block] again), and another event to clear error results.
60+
*
61+
* It is intended that you destructure the return at the call site. Here is an example usage that
62+
* calls dataSource.loadData() and then displays a UI based on the result.
63+
*
64+
* ```
65+
* val (result, onRefresh, onClearError) = launchUiStateProducer(dataSource) { loadData() }
66+
* Text(result.value)
67+
* Button(onClick = onRefresh) { Text("Refresh" }
68+
* Button(onClick = onClearError) { Text("Clear loading error") }
69+
* ```
70+
*
71+
* Repeated calls to onRefresh are conflated while a request is in progress.
72+
*
73+
* @param producer the data source to load data from
74+
* @param block suspending lambda that produces a single value from the data source
75+
* @return data state, onRefresh event, and onClearError event
76+
*/
77+
@Composable
78+
fun <Producer, T> launchUiStateProducer(
79+
producer: Producer,
80+
block: suspend Producer.() -> Result<T>
81+
): ProducerResult<UiState<T>> = launchUiStateProducer(producer, Unit, block)
82+
83+
/**
84+
* Launch a coroutine to create refreshable [UiState] from a suspending producer.
85+
*
86+
* [Producer] is any object that has a suspending method that returns [Result]. In the [block] call
87+
* the suspending method that produces a single value. The result of this call will be returned
88+
* along with an event to refresh (or call [block] again), and another event to clear error results.
89+
*
90+
* It is intended that you destructure the return at the call site. Here is an example usage that
91+
* calls dataSource.loadData(resourceId) and then displays a UI based on the result.
92+
*
93+
* ```
94+
* val (result, onRefresh, onClearError) = launchUiStateProducer(dataSource, resourceId) {
95+
* loadData(resourceId)
96+
* }
97+
* Text(result.value)
98+
* Button(onClick = onRefresh) { Text("Refresh" }
99+
* Button(onClick = onClearError) { Text("Clear loading error") }
100+
* ```
101+
*
102+
* Repeated calls to onRefresh are conflated while a request is in progress.
103+
*
104+
* @param producer the data source to load data from
105+
* @param key any argument used by production lambda, such as a resource ID
106+
* @param block suspending lambda that produces a single value from the data source
107+
* @return data state, onRefresh event, and onClearError event
108+
*/
109+
@OptIn(ExperimentalCoroutinesApi::class)
110+
@Composable
111+
fun <Producer, T> launchUiStateProducer(
112+
producer: Producer,
113+
key: Any?,
114+
block: suspend Producer.() -> Result<T>
115+
): ProducerResult<UiState<T>> {
116+
val producerState = remember { mutableStateOf(UiState<T>(loading = true)) }
117+
118+
// posting to this channel will trigger a single refresh
119+
val refreshChannel = remember { Channel<Unit>(Channel.CONFLATED) }
120+
121+
// event for caller to trigger a refresh
122+
val refresh: () -> Unit = { refreshChannel.offer(Unit) }
123+
124+
// event for caller to clear any errors on the current result (useful for transient error
125+
// displays)
126+
val clearError: () -> Unit = {
127+
producerState.value = producerState.value.copy(exception = null)
128+
}
129+
130+
// whenever Producer or v1 changes, launch a new coroutine to call block() and refresh whenever
131+
// the onRefresh callback is called
132+
launchInComposition(producer, key) {
133+
// whenever the coroutine restarts, clear the previous result immediately as they are no
134+
// longer valid
135+
producerState.value = UiState(loading = true)
136+
// force a refresh on coroutine restart
137+
refreshChannel.send(Unit)
138+
// whenever a refresh is triggered, call block again. This for-loop will suspend when
139+
// refreshChannel is empty, and resume when the next value is offered or sent to the
140+
// channel.
141+
142+
// This for-loop will loop until the [launchInComposition] coroutine is cancelled.
143+
for (refreshEvent in refreshChannel) {
144+
producerState.value = producerState.value.copy(loading = true)
145+
producerState.value = producerState.value.copyWithResult(producer.block())
146+
}
147+
}
148+
return ProducerResult(producerState, refresh, clearError)
149+
}

‎JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsStatus.kt renamed to ‎JetNews/app/src/main/java/com/example/jetnews/utils/MapExtensions.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@
1414
* limitations under the License.
1515
*/
1616

17-
package com.example.jetnews.ui
17+
package com.example.jetnews.utils
1818

19-
import androidx.compose.runtime.mutableStateListOf
20-
21-
object JetnewsStatus {
22-
val favorites = mutableStateListOf<String>()
23-
val selectedTopics = mutableStateListOf<String>()
19+
internal fun <E> MutableSet<E>.addOrRemove(element: E) {
20+
if (!add(element)) {
21+
remove(element)
22+
}
2423
}

0 commit comments

Comments
 (0)
Please sign in to comment.