parent
a163dd7929
commit
b96b3e9e40
@ -1,57 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2023 The Android Open Source Project
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="64dp"
|
|
||||||
android:height="64dp"
|
|
||||||
android:viewportWidth="64"
|
|
||||||
android:viewportHeight="64">
|
|
||||||
<path
|
|
||||||
android:pathData="M16,2H55C57.209,2 59,3.791 59,6V52"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:strokeLineCap="round">
|
|
||||||
<aapt:attr name="android:strokeColor">
|
|
||||||
<gradient
|
|
||||||
android:startX="8"
|
|
||||||
android:startY="8"
|
|
||||||
android:endX="56"
|
|
||||||
android:endY="56"
|
|
||||||
android:type="linear">
|
|
||||||
<item android:offset="0" android:color="#FFFFA8FF"/>
|
|
||||||
<item android:offset="1" android:color="#FFFF8B5E"/>
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:pathData="M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:strokeLineCap="round">
|
|
||||||
<aapt:attr name="android:strokeColor">
|
|
||||||
<gradient
|
|
||||||
android:startX="8"
|
|
||||||
android:startY="8"
|
|
||||||
android:endX="56"
|
|
||||||
android:endY="56"
|
|
||||||
android:type="linear">
|
|
||||||
<item android:offset="0" android:color="#FFFFA8FF"/>
|
|
||||||
<item android:offset="1" android:color="#FFFF8B5E"/>
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
</vector>
|
|
@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material3.SnackbarDuration.Short
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult.ActionPerformed
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.navigation3.runtime.EntryProviderBuilder
|
||||||
|
import androidx.navigation3.runtime.entry
|
||||||
|
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksRoute
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.components.ActivityComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ActivityComponent::class)
|
||||||
|
object BookmarksModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun provideBookmarksEntryProviderBuilder(
|
||||||
|
backStack: NiaBackStack,
|
||||||
|
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
|
||||||
|
entry<BookmarksRoute> {
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
BookmarksRoute(
|
||||||
|
onTopicClick = backStack::navigateToTopic,
|
||||||
|
onShowSnackbar = { message, action ->
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = action,
|
||||||
|
duration = Short,
|
||||||
|
) == ActionPerformed
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {
|
||||||
|
error("host state should be initialzied at runtime")
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2023 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="64dp"
|
||||||
|
android:height="64dp"
|
||||||
|
android:viewportWidth="64"
|
||||||
|
android:viewportHeight="64">
|
||||||
|
<path
|
||||||
|
android:pathData="M16,2H55C57.209,2 59,3.791 59,6V52"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeLineCap="round">
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="8"
|
||||||
|
android:startY="8"
|
||||||
|
android:endX="56"
|
||||||
|
android:endY="56"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FFFFA8FF"/>
|
||||||
|
<item android:offset="1" android:color="#FFFF8B5E"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeLineCap="round">
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="8"
|
||||||
|
android:startY="8"
|
||||||
|
android:endX="56"
|
||||||
|
android:endY="56"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FFFFA8FF"/>
|
||||||
|
<item android:offset="1" android:color="#FFFF8B5E"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation
|
||||||
|
|
||||||
|
import androidx.navigation3.runtime.EntryProviderBuilder
|
||||||
|
import androidx.navigation3.runtime.entry
|
||||||
|
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouBaseRoute
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.components.ActivityComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ActivityComponent::class)
|
||||||
|
object ForYouModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun provideForYouEntryProviderBuilder(
|
||||||
|
backStack: NiaBackStack,
|
||||||
|
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
|
||||||
|
entry<ForYouBaseRoute> {
|
||||||
|
ForYouScreen(
|
||||||
|
onTopicClick = backStack::navigateToTopic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.interests.impl
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.navigation.toRoute
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
const val TOPIC_ID_KEY = "selectedTopicId"
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class Interests2PaneViewModel @Inject constructor(
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val route = savedStateHandle.toRoute<InterestsRoute>()
|
||||||
|
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
|
||||||
|
key = TOPIC_ID_KEY,
|
||||||
|
initialValue = route.initialTopicId,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onTopicClick(topicId: String?) {
|
||||||
|
savedStateHandle[TOPIC_ID_KEY] = topicId
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.interests.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation3.runtime.EntryProviderBuilder
|
||||||
|
import androidx.navigation3.runtime.entry
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.components.ActivityComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ActivityComponent::class)
|
||||||
|
object InterestsModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun provideInterestsEntryProviderBuilder(): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
|
||||||
|
entry<InterestsRoute> { key ->
|
||||||
|
InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,243 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.interests.impl
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.VerticalDragHandle
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||||
|
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||||
|
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
|
||||||
|
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||||
|
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
|
||||||
|
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
|
||||||
|
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
|
||||||
|
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||||
|
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||||
|
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
|
||||||
|
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||||
|
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler
|
||||||
|
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicDetailPlaceholder
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
@Serializable internal object TopicPlaceholderRoute
|
||||||
|
|
||||||
|
fun NavGraphBuilder.interestsListDetailScreen() {
|
||||||
|
composable<InterestsRoute> {
|
||||||
|
InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun InterestsListDetailScreen(
|
||||||
|
viewModel: Interests2PaneViewModel = hiltViewModel(),
|
||||||
|
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
|
||||||
|
) {
|
||||||
|
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
|
||||||
|
InterestsListDetailScreen(
|
||||||
|
selectedTopicId = selectedTopicId,
|
||||||
|
onTopicClick = viewModel::onTopicClick,
|
||||||
|
windowAdaptiveInfo = windowAdaptiveInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun InterestsListDetailScreen(
|
||||||
|
selectedTopicId: String?,
|
||||||
|
onTopicClick: (String) -> Unit,
|
||||||
|
windowAdaptiveInfo: WindowAdaptiveInfo,
|
||||||
|
) {
|
||||||
|
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
|
||||||
|
scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo),
|
||||||
|
initialDestinationHistory = listOfNotNull(
|
||||||
|
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
|
||||||
|
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
|
||||||
|
selectedTopicId != null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val paneExpansionState = rememberPaneExpansionState(
|
||||||
|
anchors = listOf(
|
||||||
|
PaneExpansionAnchor.Proportion(0f),
|
||||||
|
PaneExpansionAnchor.Proportion(0.5f),
|
||||||
|
PaneExpansionAnchor.Proportion(1f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ThreePaneScaffoldPredictiveBackHandler(
|
||||||
|
listDetailNavigator,
|
||||||
|
BackNavigationBehavior.PopUntilScaffoldValueChange,
|
||||||
|
)
|
||||||
|
BackHandler(
|
||||||
|
paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(0f) &&
|
||||||
|
listDetailNavigator.isListPaneVisible() &&
|
||||||
|
listDetailNavigator.isDetailPaneVisible(),
|
||||||
|
) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var topicRoute by remember {
|
||||||
|
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
|
||||||
|
mutableStateOf(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTopicClickShowDetailPane(topicId: String) {
|
||||||
|
onTopicClick(topicId)
|
||||||
|
topicRoute = TopicRoute(id = topicId)
|
||||||
|
coroutineScope.launch {
|
||||||
|
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
|
||||||
|
}
|
||||||
|
if (paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(1f)) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(0f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||||
|
val minPaneWidth = 300.dp
|
||||||
|
|
||||||
|
NavigableListDetailPaneScaffold(
|
||||||
|
navigator = listDetailNavigator,
|
||||||
|
listPane = {
|
||||||
|
AnimatedPane {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.clipToBounds()
|
||||||
|
.layout { measurable, constraints ->
|
||||||
|
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
|
||||||
|
val placeable = measurable.measure(
|
||||||
|
constraints.copy(
|
||||||
|
minWidth = minPaneWidth.roundToPx(),
|
||||||
|
maxWidth = width,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
layout(constraints.maxWidth, placeable.height) {
|
||||||
|
placeable.placeRelative(
|
||||||
|
x = 0,
|
||||||
|
y = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
InterestsRoute(
|
||||||
|
onTopicClick = ::onTopicClickShowDetailPane,
|
||||||
|
shouldHighlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detailPane = {
|
||||||
|
AnimatedPane {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.clipToBounds()
|
||||||
|
.layout { measurable, constraints ->
|
||||||
|
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
|
||||||
|
val placeable = measurable.measure(
|
||||||
|
constraints.copy(
|
||||||
|
minWidth = minPaneWidth.roundToPx(),
|
||||||
|
maxWidth = width,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
layout(constraints.maxWidth, placeable.height) {
|
||||||
|
placeable.placeRelative(
|
||||||
|
x = constraints.maxWidth -
|
||||||
|
max(constraints.maxWidth, placeable.width),
|
||||||
|
y = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
AnimatedContent(topicRoute) { route ->
|
||||||
|
when (route) {
|
||||||
|
is TopicRoute -> {
|
||||||
|
TopicScreen(
|
||||||
|
showBackButton = !listDetailNavigator.isListPaneVisible(),
|
||||||
|
onBackClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
listDetailNavigator.navigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTopicClick = ::onTopicClickShowDetailPane,
|
||||||
|
viewModel = hiltViewModel<TopicViewModel, TopicViewModel.Factory>(
|
||||||
|
key = route.id,
|
||||||
|
) { factory ->
|
||||||
|
factory.create(route.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is TopicPlaceholderRoute -> {
|
||||||
|
TopicDetailPlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paneExpansionState = paneExpansionState,
|
||||||
|
paneExpansionDragHandle = {
|
||||||
|
VerticalDragHandle(
|
||||||
|
modifier = Modifier.paneExpansionDraggable(
|
||||||
|
state = paneExpansionState,
|
||||||
|
minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
|
||||||
|
interactionSource = mutableInteractionSource,
|
||||||
|
semanticsProperties = paneExpansionState.defaultDragHandleSemantics(),
|
||||||
|
),
|
||||||
|
interactionSource = mutableInteractionSource,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
|
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
|
||||||
|
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
|
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
|
||||||
|
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
|
@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.interests.impl
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.espresso.Espresso
|
||||||
|
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
|
||||||
|
import com.google.samples.apps.nowinandroid.core.model.data.Topic
|
||||||
|
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.R as FeatureTopicR
|
||||||
|
|
||||||
|
private const val EXPANDED_WIDTH = "w1200dp-h840dp"
|
||||||
|
private const val COMPACT_WIDTH = "w412dp-h915dp"
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(application = HiltTestApplication::class)
|
||||||
|
class InterestsListDetailScreenTest {
|
||||||
|
|
||||||
|
@get:Rule(order = 0)
|
||||||
|
val hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@get:Rule(order = 1)
|
||||||
|
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var topicsRepository: TopicsRepository
|
||||||
|
|
||||||
|
/** Convenience function for getting all topics during tests, */
|
||||||
|
private fun getTopics(): List<Topic> = runBlocking {
|
||||||
|
topicsRepository.getTopics().first().sortedBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strings used for matching in these tests.
|
||||||
|
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_api_select_an_interest)
|
||||||
|
private val listPaneTag = "interests:topics"
|
||||||
|
|
||||||
|
private val Topic.testTag
|
||||||
|
get() = "topic:${this.id}"
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
hiltRule.inject()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = EXPANDED_WIDTH)
|
||||||
|
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
|
||||||
|
composeTestRule.apply {
|
||||||
|
setContent {
|
||||||
|
NiaTheme {
|
||||||
|
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithTag(listPaneTag).assertIsDisplayed()
|
||||||
|
onNodeWithText(placeholderText).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = COMPACT_WIDTH)
|
||||||
|
fun compactWidth_initialState_showsListPane() {
|
||||||
|
composeTestRule.apply {
|
||||||
|
setContent {
|
||||||
|
NiaTheme {
|
||||||
|
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithTag(listPaneTag).assertIsDisplayed()
|
||||||
|
onNodeWithText(placeholderText).assertIsNotDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = EXPANDED_WIDTH)
|
||||||
|
fun expandedWidth_topicSelected_updatesDetailPane() {
|
||||||
|
composeTestRule.apply {
|
||||||
|
setContent {
|
||||||
|
NiaTheme {
|
||||||
|
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstTopic = getTopics().first()
|
||||||
|
onNodeWithText(firstTopic.name).performClick()
|
||||||
|
|
||||||
|
onNodeWithTag(listPaneTag).assertIsDisplayed()
|
||||||
|
onNodeWithText(placeholderText).assertIsNotDisplayed()
|
||||||
|
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = COMPACT_WIDTH)
|
||||||
|
fun compactWidth_topicSelected_showsTopicDetailPane() {
|
||||||
|
composeTestRule.apply {
|
||||||
|
setContent {
|
||||||
|
NiaTheme {
|
||||||
|
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstTopic = getTopics().first()
|
||||||
|
onNodeWithText(firstTopic.name).performClick()
|
||||||
|
|
||||||
|
onNodeWithTag(listPaneTag).assertIsNotDisplayed()
|
||||||
|
onNodeWithText(placeholderText).assertIsNotDisplayed()
|
||||||
|
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = EXPANDED_WIDTH)
|
||||||
|
fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
|
||||||
|
var unhandledBackPress = false
|
||||||
|
composeTestRule.apply {
|
||||||
|
setContent {
|
||||||
|
NiaTheme {
|
||||||
|
// Back press should not be handled by the two pane layout, and thus
|
||||||
|
// "fall through" to this BackHandler.
|
||||||
|
BackHandler {
|
||||||
|
unhandledBackPress = true
|
||||||
|
}
|
||||||
|
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstTopic = getTopics().first()
|
||||||
|
onNodeWithText(firstTopic.name).performClick()
|
||||||
|
|
||||||
|
waitForIdle()
|
||||||
|
Espresso.pressBack()
|
||||||
|
|
||||||
|
assertTrue(unhandledBackPress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = COMPACT_WIDTH)
|
||||||
|
fun compactWidth_backPressFromTopicDetail_showsListPane() {
|
||||||
|
composeTestRule.apply {
|
||||||
|
setContent {
|
||||||
|
NiaTheme {
|
||||||
|
com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsListDetailScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstTopic = getTopics().first()
|
||||||
|
onNodeWithText(firstTopic.name).performClick()
|
||||||
|
|
||||||
|
waitForIdle()
|
||||||
|
Espresso.pressBack()
|
||||||
|
|
||||||
|
onNodeWithTag(listPaneTag).assertIsDisplayed()
|
||||||
|
onNodeWithText(placeholderText).assertIsNotDisplayed()
|
||||||
|
onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidComposeTestRule<*, *>.stringResource(
|
||||||
|
@StringRes resId: Int,
|
||||||
|
): ReadOnlyProperty<Any, String> =
|
||||||
|
ReadOnlyProperty { _, _ -> activity.getString(resId) }
|
@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.search.impl.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.navigation3.runtime.EntryProviderBuilder
|
||||||
|
import androidx.navigation3.runtime.entry
|
||||||
|
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.navigateToInterests
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRouteNav3
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.search.impl.SearchRoute
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.components.ActivityComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ActivityComponent::class)
|
||||||
|
object SearchModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun provideSearchEntryProviderBuilder(
|
||||||
|
backStack: NiaBackStack,
|
||||||
|
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
|
||||||
|
entry<SearchRouteNav3> { key ->
|
||||||
|
SearchRoute(
|
||||||
|
onBackClick = backStack::removeLast,
|
||||||
|
onInterestsClick = key.onInterestsClick,
|
||||||
|
onTopicClick = backStack::navigateToInterests,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.google.samples.apps.nowinandroid.feature.topic.impl.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation3.runtime.EntryProviderBuilder
|
||||||
|
import androidx.navigation3.runtime.entry
|
||||||
|
import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel.Factory
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.components.ActivityComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ActivityComponent::class)
|
||||||
|
object TopicModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@IntoSet
|
||||||
|
fun provideTopicEntryProviderBuilder(
|
||||||
|
backStack: NiaBackStack,
|
||||||
|
): EntryProviderBuilder<Any>.() -> @JvmSuppressWildcards Unit = {
|
||||||
|
entry<TopicRoute> { key ->
|
||||||
|
val id = key.id
|
||||||
|
TopicScreen(
|
||||||
|
showBackButton = true,
|
||||||
|
onBackClick = backStack::removeLast,
|
||||||
|
onTopicClick = backStack::navigateToTopic,
|
||||||
|
viewModel = hiltViewModel<TopicViewModel, Factory>(
|
||||||
|
key = id,
|
||||||
|
) { factory ->
|
||||||
|
factory.create(id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue