@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 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, null)
|
||||||
|
|
||||||
|
fun onTopicClick(topicId: String?) {
|
||||||
|
savedStateHandle[TOPIC_ID_ARG] = topicId
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
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.navigation.ThreePaneScaffoldNavigator
|
||||||
|
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.navigation.TOPIC_ROUTE
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
|
||||||
|
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
|
||||||
|
|
||||||
|
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<Nothing>()
|
||||||
|
BackHandler(listDetailNavigator.canNavigateBack()) {
|
||||||
|
listDetailNavigator.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
val nestedNavController = rememberNavController()
|
||||||
|
|
||||||
|
fun onTopicClickShowDetailPane(topicId: String) {
|
||||||
|
onTopicClick(topicId)
|
||||||
|
nestedNavController.navigateToTopic(topicId) {
|
||||||
|
popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
|
||||||
|
}
|
||||||
|
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
ListDetailPaneScaffold(
|
||||||
|
value = listDetailNavigator.scaffoldValue,
|
||||||
|
directive = listDetailNavigator.scaffoldDirective,
|
||||||
|
listPane = {
|
||||||
|
InterestsRoute(
|
||||||
|
onTopicClick = ::onTopicClickShowDetailPane,
|
||||||
|
highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
detailPane = {
|
||||||
|
NavHost(
|
||||||
|
navController = nestedNavController,
|
||||||
|
startDestination = TOPIC_ROUTE,
|
||||||
|
route = DETAIL_PANE_NAVHOST_ROUTE,
|
||||||
|
) {
|
||||||
|
topicScreen(
|
||||||
|
showBackButton = !listDetailNavigator.isListPaneVisible(),
|
||||||
|
onBackClick = listDetailNavigator::navigateBack,
|
||||||
|
onTopicClick = ::onTopicClickShowDetailPane,
|
||||||
|
)
|
||||||
|
composable(route = TOPIC_ROUTE) {
|
||||||
|
Box {
|
||||||
|
Text("Placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (selectedTopicId != null) {
|
||||||
|
// Initial topic ID was provided when navigating to Interests, so show its details.
|
||||||
|
onTopicClickShowDetailPane(selectedTopicId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 121 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 102 KiB |
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 266 KiB |
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 139 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 78 KiB |
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 178 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 103 KiB |
@ -0,0 +1,6 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
java.time.ZoneId
|
||||||
|
java.time.ZoneOffset
|
@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* 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.test
|
||||||
|
|
||||||
|
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor {
|
||||||
|
override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of("Europe/Warsaw"))
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.0 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |