Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ListDetailPaneScaffold #970

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@
package com.google.samples.apps.nowinandroid.navigation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_NAVIGATION_ROUTE
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
Expand All @@ -49,23 +55,35 @@ fun NiaNavHost(
startDestination = startDestination,
modifier = modifier,
) {
forYouScreen(onTopicClick = navController::navigateToTopic)
forYouScreen(onTopicClick = navController::navigateToInterestsGraph)
bookmarksScreen(
onTopicClick = navController::navigateToTopic,
onTopicClick = navController::navigateToInterestsGraph,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToTopic,
onTopicClick = navController::navigateToInterestsGraph,
)
interestsGraph(
onTopicClick = navController::navigateToTopic,
nestedGraphs = {
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
detailsPane = { topicId ->
val nestedNavController = rememberNavController()
NavHost(
navController = nestedNavController,
startDestination = TOPIC_NAVIGATION_ROUTE,
) {
topicScreen(onTopicClick = nestedNavController::navigateToTopic)
}
LaunchedEffect(topicId) {
nestedNavController.navigateToTopic(
topicId,
navOptions {
popUpTo(nestedNavController.graph.findStartDestination().id) {
inclusive = true
}
},
)
}
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ class NiaAppState(
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(navOptions = topLevelNavOptions)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ fun NiaFilterChip(
},
shape = CircleShape,
border = FilterChipDefaults.filterChipBorder(
enabled = enabled,
selected = selected,
borderColor = MaterialTheme.colorScheme.onBackground,
selectedBorderColor = MaterialTheme.colorScheme.onBackground,
disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy(
Expand Down
4 changes: 3 additions & 1 deletion feature/interests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ android {
}

dependencies {
implementation(libs.androidx.compose.material3.adaptive)

implementation(projects.core.data)
implementation(projects.core.domain)

testImplementation(projects.core.testing)

androidTestImplementation(projects.core.testing)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = followableTopicTestData),
uiState = InterestsUiState.Interests(
selectedTopicId = null,
topics = followableTopicTestData,
),
)
}

Expand Down Expand Up @@ -110,6 +113,7 @@ class InterestsScreenTest {
uiState = uiState,
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent

@Composable
internal fun InterestsRoute(
onTopicClick: (String) -> Unit,
detailsPane: @Composable (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel(),
) {
Expand All @@ -45,7 +45,8 @@ internal fun InterestsRoute(
InterestsScreen(
uiState = uiState,
followTopic = viewModel::followTopic,
onTopicClick = onTopicClick,
onTopicClick = viewModel::onTopicClick,
detailsPane = detailsPane,
modifier = modifier,
)
}
Expand All @@ -55,6 +56,7 @@ internal fun InterestsScreen(
uiState: InterestsUiState,
followTopic: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
detailsPane: @Composable (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
Expand All @@ -67,13 +69,17 @@ internal fun InterestsScreen(
modifier = modifier,
contentDesc = stringResource(id = R.string.feature_interests_loading),
)

is InterestsUiState.Interests ->
TopicsTabContent(
topics = uiState.topics,
selectedTopicId = uiState.selectedTopicId,
onTopicClick = onTopicClick,
onFollowButtonClick = followTopic,
detailsPane = detailsPane,
modifier = modifier,
)

is InterestsUiState.Empty -> InterestsEmptyScreen()
}
}
Expand All @@ -96,9 +102,11 @@ fun InterestsScreenPopulated(
InterestsScreen(
uiState = InterestsUiState.Interests(
topics = followableTopics,
selectedTopicId = followableTopics.first().topic.id,
),
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}
Expand All @@ -113,6 +121,7 @@ fun InterestsScreenLoading() {
uiState = InterestsUiState.Loading,
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}
Expand All @@ -127,6 +136,7 @@ fun InterestsScreenEmpty() {
uiState = InterestsUiState.Empty,
followTopic = { _, _ -> },
onTopicClick = {},
detailsPane = {},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,46 +16,57 @@

package com.google.samples.apps.nowinandroid.feature.interests

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class InterestsViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {

val uiState: StateFlow<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
val uiState: StateFlow<InterestsUiState> = combine(
savedStateHandle.getStateFlow<String?>(TOPIC_ID_ARG, null),
getFollowableTopics(sortBy = TopicSortField.NAME),
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)

fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {
userDataRepository.setTopicIdFollowed(followedTopicId, followed)
}
}

fun onTopicClick(topicId: String) {
viewModelScope.launch {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need for a launch here if we aren't calling a suspending function.

savedStateHandle[TOPIC_ID_ARG] = topicId
}
}
}

sealed interface InterestsUiState {
data object Loading : InterestsUiState

data class Interests(
val selectedTopicId: String?,
val topics: List<FollowableTopic>,
) : InterestsUiState

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,27 @@

package com.google.samples.apps.nowinandroid.feature.interests

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
Expand All @@ -40,44 +46,78 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollba
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun TopicsTabContent(
topics: List<FollowableTopic>,
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
detailsPane: @Composable (String) -> Unit,
) {
val listDetailPaneNavigator = rememberListDetailPaneScaffoldNavigator<String>()

BackHandler(enabled = listDetailPaneNavigator.canNavigateBack()) {
listDetailPaneNavigator.navigateBack()
}

LaunchedEffect(selectedTopicId) {
if (selectedTopicId != null) {
listDetailPaneNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
}

ListDetailPaneScaffold(
scaffoldState = listDetailPaneNavigator.scaffoldState,
listPane = {
ListPane(
topics = topics,
onTopicClick = onTopicClick,
onFollowButtonClick = onFollowButtonClick,
)
},
detailPane = {
if (selectedTopicId != null) {
detailsPane(selectedTopicId)
}
},
modifier = modifier,
)
}

@Composable
private fun ListPane(
topics: List<FollowableTopic>,
onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true,
) {
Box(
modifier = modifier
.fillMaxWidth(),
modifier = modifier.fillMaxSize(),
) {
val scrollableState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(horizontal = 24.dp)
.testTag("interests:topics"),
contentPadding = PaddingValues(vertical = 16.dp),
modifier = Modifier.testTag("interests:topics"),
state = scrollableState,
) {
topics.forEach { followableTopic ->
items(
items = topics,
key = { followableTopic -> followableTopic.topic.id },
) { followableTopic ->
val topicId = followableTopic.topic.id
item(key = topicId) {
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}

if (withBottomSpacer) {
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
val scrollbarState = scrollableState.scrollbarState(
Expand Down
Loading
Loading