Skip to content

Commit a051cbe

Browse files
committed
Merge remote-tracking branch 'github/main' into bug-fix459/top-bar2
2 parents 69f5a2c + 27ba312 commit a051cbe

File tree

25 files changed

+962
-417
lines changed

25 files changed

+962
-417
lines changed

Crane/app/src/release/res/values/google_maps_api.xml

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

JetNews/app/build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,16 @@ dependencies {
106106
implementation "com.google.accompanist:accompanist-insets:$accompanist_version"
107107
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
108108

109-
implementation 'androidx.appcompat:appcompat:1.3.0'
110-
implementation 'androidx.activity:activity-ktx:1.2.3'
109+
implementation 'androidx.appcompat:appcompat:1.3.1'
110+
implementation 'androidx.activity:activity-ktx:1.3.1'
111111
implementation 'androidx.core:core-ktx:1.6.0'
112112
implementation "androidx.activity:activity-compose:1.3.1"
113113

114114
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1"
115115
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"
116116
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07"
117117

118-
implementation 'androidx.navigation:navigation-compose:2.4.0-alpha06'
118+
implementation 'androidx.navigation:navigation-compose:2.4.0-alpha07'
119119

120120
androidTestImplementation 'androidx.test:core:1.4.0'
121121
androidTestImplementation 'androidx.test:rules:1.4.0'

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,10 @@ package com.example.jetnews.data
2020
* A generic class that holds a value or an exception
2121
*/
2222
sealed class Result<out R> {
23-
2423
data class Success<out T>(val data: T) : Result<T>()
2524
data class Error(val exception: Exception) : Result<Nothing>()
2625
}
2726

28-
/**
29-
* `true` if [Result] is of type [Success] & holds non-null [Success.data].
30-
*/
31-
val Result<*>.succeeded
32-
get() = this is Result.Success && data != null
33-
3427
fun <T> Result<T>.successOr(fallback: T): T {
3528
return (this as? Result.Success<T>)?.data ?: fallback
3629
}

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@ import androidx.compose.material.rememberScaffoldState
2121
import androidx.compose.runtime.Composable
2222
import androidx.compose.runtime.remember
2323
import androidx.compose.runtime.rememberCoroutineScope
24+
import androidx.lifecycle.viewmodel.compose.viewModel
2425
import androidx.navigation.NavHostController
26+
import androidx.navigation.NavType
2527
import androidx.navigation.compose.NavHost
2628
import androidx.navigation.compose.composable
29+
import androidx.navigation.compose.navArgument
2730
import androidx.navigation.compose.rememberNavController
2831
import com.example.jetnews.data.AppContainer
29-
import com.example.jetnews.ui.MainDestinations.ARTICLE_ID_KEY
3032
import com.example.jetnews.ui.article.ArticleScreen
33+
import com.example.jetnews.ui.article.ArticleViewModel
34+
import com.example.jetnews.ui.article.ArticleViewModel.Companion.ARTICLE_ID_KEY
3135
import com.example.jetnews.ui.home.HomeScreen
36+
import com.example.jetnews.ui.home.HomeViewModel
3237
import com.example.jetnews.ui.interests.InterestsScreen
38+
import com.example.jetnews.ui.interests.InterestsViewModel
3339
import kotlinx.coroutines.launch
3440

3541
/**
@@ -39,7 +45,6 @@ object MainDestinations {
3945
const val HOME_ROUTE = "home"
4046
const val INTERESTS_ROUTE = "interests"
4147
const val ARTICLE_ROUTE = "post"
42-
const val ARTICLE_ID_KEY = "postId"
4348
}
4449

4550
@Composable
@@ -58,23 +63,39 @@ fun JetnewsNavGraph(
5863
startDestination = startDestination
5964
) {
6065
composable(MainDestinations.HOME_ROUTE) {
66+
val homeViewModel: HomeViewModel = viewModel(
67+
factory = HomeViewModel.provideFactory(appContainer.postsRepository)
68+
)
6169
HomeScreen(
62-
postsRepository = appContainer.postsRepository,
70+
homeViewModel = homeViewModel,
6371
navigateToArticle = actions.navigateToArticle,
6472
openDrawer = openDrawer
6573
)
6674
}
6775
composable(MainDestinations.INTERESTS_ROUTE) {
76+
val interestsViewModel: InterestsViewModel = viewModel(
77+
factory = InterestsViewModel.provideFactory(appContainer.interestsRepository)
78+
)
6879
InterestsScreen(
69-
interestsRepository = appContainer.interestsRepository,
80+
interestsViewModel = interestsViewModel,
7081
openDrawer = openDrawer
7182
)
7283
}
73-
composable("${MainDestinations.ARTICLE_ROUTE}/{$ARTICLE_ID_KEY}") { backStackEntry ->
84+
composable(
85+
route = "${MainDestinations.ARTICLE_ROUTE}/{$ARTICLE_ID_KEY}",
86+
arguments = listOf(navArgument(ARTICLE_ID_KEY) { type = NavType.StringType })
87+
) { backStackEntry ->
88+
// ArticleVM obtains the articleId via backStackEntry.arguments from SavedStateHandle
89+
val articleViewModel: ArticleViewModel = viewModel(
90+
factory = ArticleViewModel.provideFactory(
91+
postsRepository = appContainer.postsRepository,
92+
owner = backStackEntry,
93+
defaultArgs = backStackEntry.arguments
94+
)
95+
)
7496
ArticleScreen(
75-
postId = backStackEntry.arguments?.getString(ARTICLE_ID_KEY),
76-
onBack = actions.upPress,
77-
postsRepository = appContainer.postsRepository
97+
articleViewModel = articleViewModel,
98+
onBack = actions.upPress
7899
)
79100
}
80101
}

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

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ import androidx.compose.material.icons.filled.ArrowBack
4343
import androidx.compose.material.icons.filled.Share
4444
import androidx.compose.material.icons.filled.ThumbUpOffAlt
4545
import androidx.compose.runtime.Composable
46+
import androidx.compose.runtime.LaunchedEffect
4647
import androidx.compose.runtime.collectAsState
4748
import androidx.compose.runtime.getValue
4849
import androidx.compose.runtime.mutableStateOf
49-
import androidx.compose.runtime.rememberCoroutineScope
5050
import androidx.compose.runtime.saveable.rememberSaveable
5151
import androidx.compose.runtime.setValue
5252
import androidx.compose.ui.Alignment
@@ -60,58 +60,47 @@ import androidx.compose.ui.tooling.preview.Preview
6060
import androidx.compose.ui.unit.dp
6161
import com.example.jetnews.R
6262
import com.example.jetnews.data.Result
63-
import com.example.jetnews.data.posts.PostsRepository
6463
import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository
6564
import com.example.jetnews.data.posts.impl.post3
6665
import com.example.jetnews.model.Post
6766
import com.example.jetnews.ui.components.InsetAwareTopAppBar
6867
import com.example.jetnews.ui.home.BookmarkButton
6968
import com.example.jetnews.ui.theme.JetnewsTheme
7069
import com.example.jetnews.utils.isScrolled
71-
import com.example.jetnews.utils.produceUiState
7270
import com.example.jetnews.utils.supportWideScreen
7371
import com.google.accompanist.insets.navigationBarsPadding
74-
import kotlinx.coroutines.launch
7572
import kotlinx.coroutines.runBlocking
7673

7774
/**
78-
* Stateful Article Screen that manages state using [produceUiState]
75+
* Displays the Article screen.
7976
*
80-
* @param postId (state) the post to show
81-
* @param postsRepository data source for this screen
77+
* @param articleViewModel ViewModel that handles the business logic of this screen
8278
* @param onBack (event) request back navigation
8379
*/
84-
@Suppress("DEPRECATION") // allow ViewModelLifecycleScope call
8580
@Composable
8681
fun ArticleScreen(
87-
postId: String?,
88-
postsRepository: PostsRepository,
82+
articleViewModel: ArticleViewModel,
8983
onBack: () -> Unit
9084
) {
91-
val (post) = produceUiState(postsRepository, postId) {
92-
getPost(postId)
93-
}
94-
// TODO: handle errors when the repository is capable of creating them
95-
val postData = post.value.data ?: return
96-
97-
// [collectAsState] will automatically collect a Flow<T> and return a State<T> object that
98-
// updates whenever the Flow emits a value. Collection is cancelled when [collectAsState] is
99-
// removed from the composition tree.
100-
val favorites by postsRepository.observeFavorites().collectAsState(setOf())
101-
val isFavorite = favorites.contains(postId)
85+
// UiState of the ArticleScreen
86+
val uiState by articleViewModel.uiState.collectAsState()
10287

103-
// Returns a [CoroutineScope] that is scoped to the lifecycle of [ArticleScreen]. When this
104-
// screen is removed from composition, the scope will be cancelled.
105-
val coroutineScope = rememberCoroutineScope()
88+
if (uiState.post != null) {
89+
ArticleScreen(
90+
post = uiState.post!!,
91+
onBack = onBack,
92+
isFavorite = uiState.isFavorite,
93+
onToggleFavorite = { articleViewModel.toggleFavorite() }
94+
)
95+
}
10696

107-
ArticleScreen(
108-
post = postData,
109-
onBack = onBack,
110-
isFavorite = isFavorite,
111-
onToggleFavorite = {
112-
coroutineScope.launch { postId?.let { postsRepository.toggleFavorite(postId) } }
97+
// Check for failures while loading the state
98+
// TODO: Improve UX
99+
LaunchedEffect(uiState) {
100+
if (uiState.failedLoading) {
101+
onBack()
113102
}
114-
)
103+
}
115104
}
116105

117106
/**
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2021 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.ui.article
18+
19+
import android.os.Bundle
20+
import androidx.lifecycle.AbstractSavedStateViewModelFactory
21+
import androidx.lifecycle.SavedStateHandle
22+
import androidx.lifecycle.ViewModel
23+
import androidx.lifecycle.viewModelScope
24+
import androidx.savedstate.SavedStateRegistryOwner
25+
import com.example.jetnews.data.Result
26+
import com.example.jetnews.data.posts.PostsRepository
27+
import com.example.jetnews.model.Post
28+
import kotlinx.coroutines.flow.MutableStateFlow
29+
import kotlinx.coroutines.flow.StateFlow
30+
import kotlinx.coroutines.flow.asStateFlow
31+
import kotlinx.coroutines.flow.collect
32+
import kotlinx.coroutines.flow.update
33+
import kotlinx.coroutines.launch
34+
35+
/**
36+
* UI state for the Article screen
37+
*/
38+
data class ArticleUiState(
39+
val post: Post? = null,
40+
val isFavorite: Boolean = false,
41+
val loading: Boolean = false
42+
) {
43+
/**
44+
* True if the post couldn't be found
45+
*/
46+
val failedLoading: Boolean
47+
get() = !loading && post == null
48+
}
49+
50+
class ArticleViewModel(
51+
private val postsRepository: PostsRepository,
52+
savedStateHandle: SavedStateHandle
53+
) : ViewModel() {
54+
55+
private val postId: String = savedStateHandle.get<String>(ARTICLE_ID_KEY)!!
56+
57+
// UI state exposed to the UI
58+
private val _uiState = MutableStateFlow(ArticleUiState(loading = true))
59+
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
60+
61+
init {
62+
// Load post
63+
viewModelScope.launch {
64+
val postResult = postsRepository.getPost(postId)
65+
_uiState.update {
66+
when (postResult) {
67+
is Result.Success -> it.copy(post = postResult.data, loading = false)
68+
is Result.Error -> it.copy(loading = false)
69+
}
70+
}
71+
}
72+
73+
// Update whether the post is favorite or not
74+
viewModelScope.launch {
75+
postsRepository.observeFavorites().collect { favorites ->
76+
_uiState.update { it.copy(isFavorite = favorites.contains(postId)) }
77+
}
78+
}
79+
}
80+
81+
fun toggleFavorite() {
82+
viewModelScope.launch {
83+
postsRepository.toggleFavorite(postId)
84+
}
85+
}
86+
87+
/**
88+
* Factory for ArticleViewModel that takes PostsRepository as a dependency
89+
*/
90+
companion object {
91+
const val ARTICLE_ID_KEY = "postId"
92+
93+
fun provideFactory(
94+
postsRepository: PostsRepository,
95+
owner: SavedStateRegistryOwner,
96+
defaultArgs: Bundle? = null,
97+
): AbstractSavedStateViewModelFactory =
98+
object : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
99+
@Suppress("UNCHECKED_CAST")
100+
override fun <T : ViewModel?> create(
101+
key: String,
102+
modelClass: Class<T>,
103+
handle: SavedStateHandle
104+
): T {
105+
return ArticleViewModel(postsRepository, handle) as T
106+
}
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)