@ -1,17 +1,25 @@
|
||||
Thanks for submitting a pull request. Please include the following information.
|
||||
**DO NOT CREATE A PULL REQUEST WITHOUT READING THESE INSTRUCTIONS**
|
||||
|
||||
**What I have done and why**
|
||||
Include a summary of what your pull request contains, and why you have made these changes.
|
||||
## Instructions
|
||||
Thanks for submitting a pull request. To accept your pull request we need you do a few things:
|
||||
|
||||
**If this is your first pull request**
|
||||
|
||||
- [Sign the contributors license agreement](https://cla.developers.google.com/)
|
||||
|
||||
**Ensure tests pass and code is formatted correctly**
|
||||
|
||||
Fixes #<issue_number_goes_here>
|
||||
- Run local tests on the `DemoDebug` variant by running `./gradlew testDemoDebug`
|
||||
- Fix code formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
|
||||
|
||||
**Do tests pass?**
|
||||
- [ ] Run local tests on `DemoDebug` variant: `./gradlew testDemoDebug`
|
||||
- [ ] Check formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
|
||||
**Add a description**
|
||||
|
||||
**Is this your first pull request?**
|
||||
- [ ] [Sign the CLA](https://cla.developers.google.com/)
|
||||
- [ ] Run `./tools/setup.sh`
|
||||
- [ ] Import the code formatting style as explained in [the setup script](/tools/setup.sh#L40).
|
||||
We need to know what you've done and why you've done it. Include a summary of what your pull request contains, and why you have made these changes. Include links to any relevant issues which it fixes.
|
||||
|
||||
[Here's an example](https://github.com/android/nowinandroid/pull/1257).
|
||||
|
||||
**NOW DELETE THIS LINE AND EVERYTHING ABOVE IT**
|
||||
|
||||
**What I have done and why**
|
||||
|
||||
\<add your PR description here\>
|
||||
|
@ -1,3 +1,3 @@
|
||||
# :app-nia-catalog module
|
||||
|
||||
![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.png)
|
||||
## Dependency graph
|
||||
![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.svg)
|
||||
|
@ -1,3 +1,3 @@
|
||||
# :app module
|
||||
|
||||
![Dependency graph](../docs/images/graphs/dep_graph_app.png)
|
||||
## Dependency graph
|
||||
![Dependency graph](../docs/images/graphs/dep_graph_app.svg)
|
||||
|
@ -1,268 +0,0 @@
|
||||
/*
|
||||
* 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.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.testharness.TestHarness
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
|
||||
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
|
||||
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
|
||||
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tests that the navigation UI is rendered correctly on different screen sizes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@HiltAndroidTest
|
||||
class NavigationUiTest {
|
||||
|
||||
/**
|
||||
* Manages the components' state and is used to perform injection on your test
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
/**
|
||||
* Create a temporary folder used to create a Data Store file. This guarantees that
|
||||
* the file is removed in between each test, preventing a crash.
|
||||
*/
|
||||
@BindValue
|
||||
@get:Rule(order = 1)
|
||||
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
|
||||
|
||||
/**
|
||||
* Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
|
||||
*/
|
||||
@get:Rule(order = 2)
|
||||
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
|
||||
|
||||
/**
|
||||
* Use a test activity to set the content on.
|
||||
*/
|
||||
@get:Rule(order = 3)
|
||||
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
|
||||
|
||||
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
|
||||
newsRepository = TestNewsRepository(),
|
||||
userDataRepository = TestUserDataRepository(),
|
||||
)
|
||||
|
||||
@Inject
|
||||
lateinit var networkMonitor: NetworkMonitor
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactWidth_compactHeight_showsNavigationBar() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(400.dp, 400.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediumWidth_compactHeight_showsNavigationRail() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(610.dp, 400.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandedWidth_compactHeight_showsNavigationRail() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(900.dp, 400.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactWidth_mediumHeight_showsNavigationBar() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(400.dp, 500.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediumWidth_mediumHeight_showsNavigationRail() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(610.dp, 500.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandedWidth_mediumHeight_showsNavigationRail() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(900.dp, 500.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactWidth_expandedHeight_showsNavigationBar() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(400.dp, 1000.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediumWidth_expandedHeight_showsNavigationRail() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(610.dp, 1000.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandedWidth_expandedHeight_showsNavigationRail() {
|
||||
composeTestRule.setContent {
|
||||
TestHarness(size = DpSize(900.dp, 1000.dp)) {
|
||||
BoxWithConstraints {
|
||||
NiaApp(
|
||||
windowSizeClass = WindowSizeClass.calculateFromSize(
|
||||
DpSize(maxWidth, maxHeight),
|
||||
),
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2024 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.ui.interests2pane
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class Interests2PaneViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val selectedTopicId: StateFlow<String?> =
|
||||
savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG])
|
||||
|
||||
fun onTopicClick(topicId: String?) {
|
||||
savedStateHandle[TOPIC_ID_ARG] = topicId
|
||||
}
|
||||
}
|
@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2024 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.ui.interests2pane
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
|
||||
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
|
||||
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
|
||||
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
|
||||
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE
|
||||
import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute
|
||||
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
|
||||
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
|
||||
import java.util.UUID
|
||||
|
||||
private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route"
|
||||
|
||||
fun NavGraphBuilder.interestsListDetailScreen() {
|
||||
composable(
|
||||
route = INTERESTS_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(TOPIC_ID_ARG) {
|
||||
type = NavType.StringType
|
||||
defaultValue = null
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
) {
|
||||
InterestsListDetailScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InterestsListDetailScreen(
|
||||
viewModel: Interests2PaneViewModel = hiltViewModel(),
|
||||
) {
|
||||
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
|
||||
InterestsListDetailScreen(
|
||||
selectedTopicId = selectedTopicId,
|
||||
onTopicClick = viewModel::onTopicClick,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
internal fun InterestsListDetailScreen(
|
||||
selectedTopicId: String?,
|
||||
onTopicClick: (String) -> Unit,
|
||||
) {
|
||||
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
|
||||
initialDestinationHistory = listOfNotNull(
|
||||
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
|
||||
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
|
||||
selectedTopicId != null
|
||||
},
|
||||
),
|
||||
)
|
||||
BackHandler(listDetailNavigator.canNavigateBack()) {
|
||||
listDetailNavigator.navigateBack()
|
||||
}
|
||||
|
||||
var nestedNavHostStartDestination by remember {
|
||||
mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE)
|
||||
}
|
||||
var nestedNavKey by rememberSaveable(
|
||||
stateSaver = Saver({ it.toString() }, UUID::fromString),
|
||||
) {
|
||||
mutableStateOf(UUID.randomUUID())
|
||||
}
|
||||
val nestedNavController = key(nestedNavKey) {
|
||||
rememberNavController()
|
||||
}
|
||||
|
||||
fun onTopicClickShowDetailPane(topicId: String) {
|
||||
onTopicClick(topicId)
|
||||
if (listDetailNavigator.isDetailPaneVisible()) {
|
||||
// If the detail pane was visible, then use the nestedNavController navigate call
|
||||
// directly
|
||||
nestedNavController.navigateToTopic(topicId) {
|
||||
popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, recreate the NavHost entirely, and start at the new destination
|
||||
nestedNavHostStartDestination = createTopicRoute(topicId)
|
||||
nestedNavKey = UUID.randomUUID()
|
||||
}
|
||||
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
value = listDetailNavigator.scaffoldValue,
|
||||
directive = listDetailNavigator.scaffoldDirective,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
InterestsRoute(
|
||||
onTopicClick = ::onTopicClickShowDetailPane,
|
||||
highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
key(nestedNavKey) {
|
||||
NavHost(
|
||||
navController = nestedNavController,
|
||||
startDestination = nestedNavHostStartDestination,
|
||||
route = DETAIL_PANE_NAVHOST_ROUTE,
|
||||
) {
|
||||
topicScreen(
|
||||
showBackButton = !listDetailNavigator.isListPaneVisible(),
|
||||
onBackClick = listDetailNavigator::navigateBack,
|
||||
onTopicClick = ::onTopicClickShowDetailPane,
|
||||
)
|
||||
composable(route = TOPIC_ROUTE) {
|
||||
TopicDetailPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@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,70 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* 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.util
|
||||
|
||||
import android.util.Log
|
||||
import androidx.profileinstaller.ProfileVerifier
|
||||
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.guava.await
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
|
||||
*
|
||||
* When delivering through Google Play, the baseline profile is compiled during installation.
|
||||
* In this case you will see the correct state logged without any further action necessary.
|
||||
* To verify baseline profile installation locally, you need to manually trigger baseline
|
||||
* profile installation.
|
||||
*
|
||||
* For immediate compilation, call:
|
||||
* ```bash
|
||||
* adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target
|
||||
* ```
|
||||
* You can also trigger background optimizations:
|
||||
* ```bash
|
||||
* adb shell pm bg-dexopt-job
|
||||
* ```
|
||||
* Both jobs run asynchronously and might take some time complete.
|
||||
*
|
||||
* To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
|
||||
* If you don't do either of these steps, you might only see the profile status reported as
|
||||
* "enqueued for compilation" when running the sample locally.
|
||||
*
|
||||
* @see androidx.profileinstaller.ProfileVerifier.CompilationStatus.ResultCode
|
||||
*/
|
||||
class ProfileVerifierLogger @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ProfileInstaller"
|
||||
}
|
||||
|
||||
operator fun invoke() = scope.launch {
|
||||
val status = ProfileVerifier.getCompilationStatusAsync().await()
|
||||
Log.d(TAG, "Status code: ${status.profileInstallResultCode}")
|
||||
Log.d(
|
||||
TAG,
|
||||
when {
|
||||
status.isCompiledWithProfile -> "App compiled with profile"
|
||||
status.hasProfileEnqueuedForCompilation() -> "Profile enqueued for compilation"
|
||||
else -> "Profile not compiled nor enqueued"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2024 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.ui
|
||||
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.test.DeviceConfigurationOverride
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.children
|
||||
|
||||
/**
|
||||
* A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available
|
||||
* to the content under test.
|
||||
*/
|
||||
@Suppress("ktlint:standard:function-naming")
|
||||
fun DeviceConfigurationOverride.Companion.WindowInsets(
|
||||
windowInsets: WindowInsetsCompat,
|
||||
): DeviceConfigurationOverride = DeviceConfigurationOverride { contentUnderTest ->
|
||||
val currentContentUnderTest by rememberUpdatedState(contentUnderTest)
|
||||
val currentWindowInsets by rememberUpdatedState(windowInsets)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
object : FrameLayout(context) {
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
children.forEach {
|
||||
it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets())
|
||||
}
|
||||
return WindowInsetsCompat.CONSUMED.toWindowInsets()!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated, but intercept the `requestApplyInsets` call via the deprecated
|
||||
* method.
|
||||
*/
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun requestFitSystemWindows() {
|
||||
dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!)
|
||||
}
|
||||
}.apply {
|
||||
addView(
|
||||
ComposeView(context).apply {
|
||||
setContent {
|
||||
currentContentUnderTest()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -0,0 +1,349 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* 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.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsEndWidth
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsStartWidth
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.material3.SnackbarDuration.Indefinite
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toAndroidRect
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.DeviceConfigurationOverride
|
||||
import androidx.compose.ui.test.ForcedSize
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpRect
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.roundToIntRect
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
|
||||
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
|
||||
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
|
||||
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import org.robolectric.annotation.LooperMode
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tests that the Snackbar is correctly displayed on different screen sizes.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
|
||||
// This allows enough room to render the content under test without clipping or scaling.
|
||||
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi")
|
||||
@LooperMode(LooperMode.Mode.PAUSED)
|
||||
@HiltAndroidTest
|
||||
class SnackbarInsetsScreenshotTests {
|
||||
|
||||
/**
|
||||
* Manages the components' state and is used to perform injection on your test
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
/**
|
||||
* Create a temporary folder used to create a Data Store file. This guarantees that
|
||||
* the file is removed in between each test, preventing a crash.
|
||||
*/
|
||||
@BindValue
|
||||
@get:Rule(order = 1)
|
||||
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
|
||||
|
||||
/**
|
||||
* Use a test activity to set the content on.
|
||||
*/
|
||||
@get:Rule(order = 2)
|
||||
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
|
||||
|
||||
@Inject
|
||||
lateinit var networkMonitor: NetworkMonitor
|
||||
|
||||
@Inject
|
||||
lateinit var timeZoneMonitor: TimeZoneMonitor
|
||||
|
||||
@Inject
|
||||
lateinit var userDataRepository: FakeUserDataRepository
|
||||
|
||||
@Inject
|
||||
lateinit var topicsRepository: TopicsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var userNewsResourceRepository: UserNewsResourceRepository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Configure user data
|
||||
runBlocking {
|
||||
userDataRepository.setShouldHideOnboarding(true)
|
||||
|
||||
userDataRepository.setFollowedTopicIds(
|
||||
setOf(topicsRepository.getTopics().first().first().id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setTimeZone() {
|
||||
// Make time zone deterministic in tests
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phone_noSnackbar() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
400.dp,
|
||||
500.dp,
|
||||
"insets_snackbar_compact_medium_noSnackbar",
|
||||
action = { },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snackbarShown_phone() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
400.dp,
|
||||
500.dp,
|
||||
"insets_snackbar_compact_medium",
|
||||
) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"This is a test snackbar message",
|
||||
actionLabel = "Action Label",
|
||||
duration = Indefinite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snackbarShown_foldable() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
600.dp,
|
||||
600.dp,
|
||||
"insets_snackbar_medium_medium",
|
||||
) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"This is a test snackbar message",
|
||||
actionLabel = "Action Label",
|
||||
duration = Indefinite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snackbarShown_tablet() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
900.dp,
|
||||
900.dp,
|
||||
"insets_snackbar_expanded_expanded",
|
||||
) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"This is a test snackbar message",
|
||||
actionLabel = "Action Label",
|
||||
duration = Indefinite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
private fun testSnackbarScreenshotWithSize(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
screenshotName: String,
|
||||
action: suspend () -> Unit,
|
||||
) {
|
||||
lateinit var scope: CoroutineScope
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(
|
||||
// Replaces images with placeholders
|
||||
LocalInspectionMode provides true,
|
||||
) {
|
||||
scope = rememberCoroutineScope()
|
||||
|
||||
DeviceConfigurationOverride(
|
||||
DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
|
||||
) {
|
||||
DeviceConfigurationOverride(
|
||||
DeviceConfigurationOverride.WindowInsets(
|
||||
WindowInsetsCompat.Builder()
|
||||
.setInsets(
|
||||
WindowInsetsCompat.Type.statusBars(),
|
||||
DpRect(
|
||||
left = 0.dp,
|
||||
top = 64.dp,
|
||||
right = 0.dp,
|
||||
bottom = 0.dp,
|
||||
).toInsets(),
|
||||
)
|
||||
.setInsets(
|
||||
WindowInsetsCompat.Type.navigationBars(),
|
||||
DpRect(
|
||||
left = 64.dp,
|
||||
top = 0.dp,
|
||||
right = 64.dp,
|
||||
bottom = 64.dp,
|
||||
).toInsets(),
|
||||
)
|
||||
.build(),
|
||||
),
|
||||
) {
|
||||
BoxWithConstraints(Modifier.testTag("root")) {
|
||||
NiaTheme {
|
||||
val appState = rememberNiaAppState(
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
timeZoneMonitor = timeZoneMonitor,
|
||||
)
|
||||
NiaApp(
|
||||
appState = appState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
showSettingsDialog = false,
|
||||
onSettingsDismissed = {},
|
||||
onTopAppBarActionClick = {},
|
||||
windowAdaptiveInfo = WindowAdaptiveInfo(
|
||||
windowSizeClass = WindowSizeClass.compute(
|
||||
maxWidth.value,
|
||||
maxHeight.value,
|
||||
),
|
||||
windowPosture = Posture(),
|
||||
),
|
||||
)
|
||||
DebugVisibleWindowInsets()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
action()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("root")
|
||||
.captureRoboImage(
|
||||
"src/testDemo/screenshots/$screenshotName.png",
|
||||
roborazziOptions = DefaultRoborazziOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DebugVisibleWindowInsets(
|
||||
modifier: Modifier = Modifier,
|
||||
debugColor: Color = Color.Magenta.copy(alpha = 0.5f),
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxHeight()
|
||||
.windowInsetsStartWidth(WindowInsets.safeDrawing)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
|
||||
.background(debugColor),
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.windowInsetsEndWidth(WindowInsets.safeDrawing)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
|
||||
.background(debugColor),
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsTopHeight(WindowInsets.safeDrawing)
|
||||
.background(debugColor),
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsBottomHeight(WindowInsets.safeDrawing)
|
||||
.background(debugColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DpRect.toInsets() = toInsets(LocalDensity.current)
|
||||
|
||||
private fun DpRect.toInsets(density: Density) =
|
||||
Insets.of(with(density) { toRect() }.roundToIntRect().toAndroidRect())
|
@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* 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.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.material3.SnackbarDuration.Indefinite
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.test.DeviceConfigurationOverride
|
||||
import androidx.compose.ui.test.ForcedSize
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
|
||||
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
|
||||
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
|
||||
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import org.robolectric.annotation.LooperMode
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tests that the Snackbar is correctly displayed on different screen sizes.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
|
||||
// This allows enough room to render the content under test without clipping or scaling.
|
||||
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi")
|
||||
@LooperMode(LooperMode.Mode.PAUSED)
|
||||
@HiltAndroidTest
|
||||
class SnackbarScreenshotTests {
|
||||
|
||||
/**
|
||||
* Manages the components' state and is used to perform injection on your test
|
||||
*/
|
||||
@get:Rule(order = 0)
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
/**
|
||||
* Create a temporary folder used to create a Data Store file. This guarantees that
|
||||
* the file is removed in between each test, preventing a crash.
|
||||
*/
|
||||
@BindValue
|
||||
@get:Rule(order = 1)
|
||||
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
|
||||
|
||||
/**
|
||||
* Use a test activity to set the content on.
|
||||
*/
|
||||
@get:Rule(order = 2)
|
||||
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
|
||||
|
||||
@Inject
|
||||
lateinit var networkMonitor: NetworkMonitor
|
||||
|
||||
@Inject
|
||||
lateinit var timeZoneMonitor: TimeZoneMonitor
|
||||
|
||||
@Inject
|
||||
lateinit var userDataRepository: FakeUserDataRepository
|
||||
|
||||
@Inject
|
||||
lateinit var topicsRepository: TopicsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var userNewsResourceRepository: UserNewsResourceRepository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Configure user data
|
||||
runBlocking {
|
||||
userDataRepository.setShouldHideOnboarding(true)
|
||||
|
||||
userDataRepository.setFollowedTopicIds(
|
||||
setOf(topicsRepository.getTopics().first().first().id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setTimeZone() {
|
||||
// Make time zone deterministic in tests
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phone_noSnackbar() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
400.dp,
|
||||
500.dp,
|
||||
"snackbar_compact_medium_noSnackbar",
|
||||
action = { },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snackbarShown_phone() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
400.dp,
|
||||
500.dp,
|
||||
"snackbar_compact_medium",
|
||||
) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"This is a test snackbar message",
|
||||
actionLabel = "Action Label",
|
||||
duration = Indefinite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snackbarShown_foldable() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
600.dp,
|
||||
600.dp,
|
||||
"snackbar_medium_medium",
|
||||
) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"This is a test snackbar message",
|
||||
actionLabel = "Action Label",
|
||||
duration = Indefinite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snackbarShown_tablet() {
|
||||
val snackbarHostState = SnackbarHostState()
|
||||
testSnackbarScreenshotWithSize(
|
||||
snackbarHostState,
|
||||
900.dp,
|
||||
900.dp,
|
||||
"snackbar_expanded_expanded",
|
||||
) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"This is a test snackbar message",
|
||||
actionLabel = "Action Label",
|
||||
duration = Indefinite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
private fun testSnackbarScreenshotWithSize(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
screenshotName: String,
|
||||
action: suspend () -> Unit,
|
||||
) {
|
||||
lateinit var scope: CoroutineScope
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(
|
||||
// Replaces images with placeholders
|
||||
LocalInspectionMode provides true,
|
||||
) {
|
||||
scope = rememberCoroutineScope()
|
||||
|
||||
DeviceConfigurationOverride(
|
||||
DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
|
||||
) {
|
||||
BoxWithConstraints {
|
||||
NiaTheme {
|
||||
val appState = rememberNiaAppState(
|
||||
networkMonitor = networkMonitor,
|
||||
userNewsResourceRepository = userNewsResourceRepository,
|
||||
timeZoneMonitor = timeZoneMonitor,
|
||||
)
|
||||
NiaApp(
|
||||
appState = appState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
showSettingsDialog = false,
|
||||
onSettingsDismissed = {},
|
||||
onTopAppBarActionClick = {},
|
||||
windowAdaptiveInfo = WindowAdaptiveInfo(
|
||||
windowSizeClass = WindowSizeClass.compute(
|
||||
maxWidth.value,
|
||||
maxHeight.value,
|
||||
),
|
||||
windowPosture = Posture(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
action()
|
||||
}
|
||||
|
||||
composeTestRule.onRoot()
|
||||
.captureRoboImage(
|
||||
"src/testDemo/screenshots/$screenshotName.png",
|
||||
roborazziOptions = DefaultRoborazziOptions,
|
||||
)
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 118 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 260 KiB |
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 139 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 193 KiB |
After Width: | Height: | Size: 77 KiB |
After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 173 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 103 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 211 KiB |
After Width: | Height: | Size: 96 KiB |
@ -0,0 +1,11 @@
|
||||
// This file contains classes (with possible wildcards) that the Compose Compiler will treat as stable.
|
||||
// It allows us to define classes that our not part of our codebase without wrapping them in a stable class.
|
||||
// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file
|
||||
|
||||
// We always use immutable classes for our data model, to avoid running the Compose compiler
|
||||
// in the module we declare it to be stable here.
|
||||
com.google.samples.apps.nowinandroid.core.model.data.*
|
||||
|
||||
// Java standard library classes
|
||||
java.time.ZoneId
|
||||
java.time.ZoneOffset
|
@ -0,0 +1,3 @@
|
||||
# :core:analytics module
|
||||
## Dependency graph
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_analytics.svg)
|
@ -1,3 +1,3 @@
|
||||
# :core:common module
|
||||
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png)
|
||||
## Dependency graph
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_common.svg)
|
||||
|
@ -1,3 +1,3 @@
|
||||
# :core:data-test module
|
||||
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.png)
|
||||
## Dependency graph
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.svg)
|
||||
|
@ -1,3 +1,3 @@
|
||||
# :core:data module
|
||||
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_data.png)
|
||||
## Dependency graph
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_core_data.svg)
|
||||
|
@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2024 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.core.data.util
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build.VERSION
|
||||
import android.os.Build.VERSION_CODES
|
||||
import androidx.tracing.trace
|
||||
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
|
||||
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
|
||||
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toKotlinTimeZone
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Utility for reporting current timezone the device has set.
|
||||
* It always emits at least once with default setting and then for each TZ change.
|
||||
*/
|
||||
interface TimeZoneMonitor {
|
||||
val currentTimeZone: Flow<TimeZone>
|
||||
}
|
||||
|
||||
@Singleton
|
||||
internal class TimeZoneBroadcastMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope appScope: CoroutineScope,
|
||||
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
|
||||
) : TimeZoneMonitor {
|
||||
|
||||
override val currentTimeZone: SharedFlow<TimeZone> =
|
||||
callbackFlow {
|
||||
// Send the default time zone first.
|
||||
trySend(TimeZone.currentSystemDefault())
|
||||
|
||||
// Registers BroadcastReceiver for the TimeZone changes
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_TIMEZONE_CHANGED) return
|
||||
|
||||
val zoneIdFromIntent = if (VERSION.SDK_INT < VERSION_CODES.R) {
|
||||
null
|
||||
} else {
|
||||
// Starting Android R we also get the new TimeZone.
|
||||
intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId ->
|
||||
// We need to convert it from java.util.Timezone to java.time.ZoneId
|
||||
val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS)
|
||||
// Convert to kotlinx.datetime.TimeZone
|
||||
zoneId.toKotlinTimeZone()
|
||||
}
|
||||
}
|
||||
|
||||
// If there isn't a zoneId in the intent, fallback to the systemDefault, which should also reflect the change
|
||||
trySend(zoneIdFromIntent ?: TimeZone.currentSystemDefault())
|
||||
}
|
||||
}
|
||||
|
||||
trace("TimeZoneBroadcastReceiver.register") {
|
||||
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
|
||||
}
|
||||
|
||||
// Send here again, because registering the Broadcast Receiver can take up to several milliseconds.
|
||||
// This way, we can reduce the likelihood that a TZ change wouldn't be caught with the Broadcast Receiver.
|
||||
trySend(TimeZone.currentSystemDefault())
|
||||
|
||||
awaitClose {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
// We use to prevent multiple emissions of the same type, because we use trySend multiple times.
|
||||
.distinctUntilChanged()
|
||||
.conflate()
|
||||
.flowOn(ioDispatcher)
|
||||
// Sharing the callback to prevent multiple BroadcastReceivers being registered
|
||||
.shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1)
|
||||
}
|