From 1e6746ddfd52bcb710537f8822bc3f6cc834250a Mon Sep 17 00:00:00 2001 From: WeiHe Date: Thu, 30 Nov 2023 23:06:57 +0800 Subject: [PATCH] chore(designsystem): Implement functionality of the designsystem module feat(navigation): Implement top-level navigation in the app --- app/build.gradle.kts | 13 +- .../java/com/wei/picquest/MainActivity.kt | 112 ++++-- .../com/wei/picquest/navigation/PqNavHost.kt | 40 ++ .../navigation/TopLevelDestination.kt | 36 ++ .../main/java/com/wei/picquest/ui/PqApp.kt | 331 +++++++++++++++++ .../java/com/wei/picquest/ui/PqAppState.kt | 211 +++++++++++ app/src/main/res/values-zh-rTW/strings.xml | 8 + app/src/main/res/values/strings.xml | 9 + app/src/main/res/values/themes.xml | 30 +- .../kotlin/AndroidFeatureConventionPlugin.kt | 2 +- .../picquest/core/manager/SnackbarManager.kt | 49 +++ .../com/wei/picquest/core/utils/UiText.kt | 69 ++++ .../wei/picquest/core/data/di/DataModule.kt | 18 + .../ConnectivityManagerNetworkMonitor.kt | 75 ++++ .../core/data/utils/NetworkMonitor.kt | 10 + .../core/designsystem/component/Background.kt | 56 +++ .../component/BaseLineHeightModifier.kt | 47 +++ .../designsystem/component/ImageLoaders.kt | 36 ++ .../core/designsystem/component/Navigation.kt | 348 ++++++++++++++++++ .../core/designsystem/component/Snackbar.kt | 104 ++++++ .../core/designsystem/component/UiExtras.kt | 36 ++ .../core/designsystem/icon/PqIcons.kt | 58 +++ .../picquest/core/designsystem/theme/Type.kt | 32 +- .../core/designsystem/ui/DeviceOrientation.kt | 20 + .../core/designsystem/ui/DevicePreviews.kt | 42 +++ .../core/designsystem/ui/WindowStateUtils.kt | 67 ++++ .../src/main/res/values-zh-rTW/strings.xml | 5 + .../src/main/res/values/strings.xml | 5 + feature/home/.gitignore | 1 + feature/home/build.gradle.kts | 12 + feature/home/src/main/AndroidManifest.xml | 4 + .../picquest/feature/home/home/HomeScreen.kt | 115 ++++++ .../home/home/navigation/HomeNavigation.kt | 23 ++ settings.gradle.kts | 2 + 34 files changed, 1957 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt create mode 100644 app/src/main/java/com/wei/picquest/navigation/TopLevelDestination.kt create mode 100644 app/src/main/java/com/wei/picquest/ui/PqApp.kt create mode 100644 app/src/main/java/com/wei/picquest/ui/PqAppState.kt create mode 100644 app/src/main/res/values-zh-rTW/strings.xml create mode 100644 core/common/src/main/java/com/wei/picquest/core/manager/SnackbarManager.kt create mode 100644 core/common/src/main/java/com/wei/picquest/core/utils/UiText.kt create mode 100644 core/data/src/main/java/com/wei/picquest/core/data/di/DataModule.kt create mode 100644 core/data/src/main/java/com/wei/picquest/core/data/utils/ConnectivityManagerNetworkMonitor.kt create mode 100644 core/data/src/main/java/com/wei/picquest/core/data/utils/NetworkMonitor.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Background.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/BaseLineHeightModifier.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/ImageLoaders.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Navigation.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Snackbar.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/UiExtras.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DeviceOrientation.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DevicePreviews.kt create mode 100644 core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/WindowStateUtils.kt create mode 100644 core/designsystem/src/main/res/values-zh-rTW/strings.xml create mode 100644 core/designsystem/src/main/res/values/strings.xml create mode 100644 feature/home/.gitignore create mode 100644 feature/home/build.gradle.kts create mode 100644 feature/home/src/main/AndroidManifest.xml create mode 100644 feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt create mode 100644 feature/home/src/main/java/com/wei/picquest/feature/home/home/navigation/HomeNavigation.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5b5839..0f921f5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,16 +37,6 @@ android { } } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - buildTypes { debug { applicationIdSuffix = PqBuildType.DEBUG.applicationIdSuffix @@ -76,9 +66,10 @@ android { } dependencies { + implementation(project(":feature:home")) + // TODO Wei // implementation(project(":feature:login")) -// implementation(project(":feature:home")) // implementation(project(":feature:contactme")) implementation(project(":core:designsystem")) diff --git a/app/src/main/java/com/wei/picquest/MainActivity.kt b/app/src/main/java/com/wei/picquest/MainActivity.kt index ce186b5..e90f4ae 100644 --- a/app/src/main/java/com/wei/picquest/MainActivity.kt +++ b/app/src/main/java/com/wei/picquest/MainActivity.kt @@ -1,46 +1,106 @@ package com.wei.picquest +import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.wei.picquest.core.data.utils.NetworkMonitor import com.wei.picquest.core.designsystem.theme.PqTheme +import com.wei.picquest.core.manager.SnackbarManager +import com.wei.picquest.ui.PqApp +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject + lateinit var snackbarManager: SnackbarManager + + @Inject + lateinit var networkMonitor: NetworkMonitor + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val splashScreen = installSplashScreen() + + splashScreen.setKeepOnScreenCondition { true } + + // Turn off the decor fitting system windows, which allows us to handle insets, + // including IME animations, and go edge-to-edge + // This also sets up the initial system bar style based on the platform theme + enableEdgeToEdge() + setContent { - PqTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Greeting("PicQuest") + val darkTheme = shouldUseDarkTheme() + + // Update the edge to edge configuration to match the theme + // This is the same parameters as the default enableEdgeToEdge call, but we manually + // resolve whether or not to show dark theme using uiState, since it can be different + // than the configuration's dark theme value based on the user preference. + DisposableEffect(darkTheme) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + Color.TRANSPARENT, + Color.TRANSPARENT, + ) { darkTheme }, + navigationBarStyle = SystemBarStyle.auto( + lightScrim, + darkScrim, + ) { darkTheme }, + ) + onDispose {} + } + + CompositionLocalProvider() { + PqTheme(darkTheme = darkTheme) { + PqApp( + networkMonitor = networkMonitor, + windowSizeClass = calculateWindowSizeClass(this@MainActivity), + displayFeatures = calculateDisplayFeatures(this@MainActivity), + snackbarManager = snackbarManager, + ) } } } + + lifecycleScope.launch { + // TODO Wei Loading user data + delay(2_000) + splashScreen.setKeepOnScreenCondition { false } + } } } +/** + * Returns `true` if dark theme should be used, as a function of the [uiState] and the + * current system context. + */ @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier, - ) -} +private fun shouldUseDarkTheme(): Boolean = isSystemInDarkTheme() -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - PqTheme { - Greeting("PicQuest") - } -} +/** + * The default light scrim, as defined by androidx and the platform: + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598 + */ +private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF) + +/** + * The default dark scrim, as defined by androidx and the platform: + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598 + */ +private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b) diff --git a/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt b/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt new file mode 100644 index 0000000..d7db6db --- /dev/null +++ b/app/src/main/java/com/wei/picquest/navigation/PqNavHost.kt @@ -0,0 +1,40 @@ +package com.wei.picquest.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.window.layout.DisplayFeature +import com.wei.picquest.core.designsystem.ui.DeviceOrientation +import com.wei.picquest.feature.home.home.navigation.homeGraph +import com.wei.picquest.feature.home.home.navigation.homeRoute +import com.wei.picquest.ui.PqAppState + +/** + * Top-level navigation graph. Navigation is organized as explained at + * https://d.android.com/jetpack/compose/nav-adaptive + * + * The navigation graph defined in this file defines the different top level routes. Navigation + * within each route is handled using state and Back Handlers. + */ +@Composable +fun PqNavHost( + modifier: Modifier = Modifier, + appState: PqAppState, + displayFeatures: List, + startDestination: String = homeRoute, +) { + val navController = appState.navController + val navigationType = appState.navigationType + val isPortrait = appState.currentDeviceOrientation == DeviceOrientation.PORTRAIT + val contentType = appState.contentType + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + ) { + homeGraph( + navController = navController, + ) + } +} diff --git a/app/src/main/java/com/wei/picquest/navigation/TopLevelDestination.kt b/app/src/main/java/com/wei/picquest/navigation/TopLevelDestination.kt new file mode 100644 index 0000000..7a22f9c --- /dev/null +++ b/app/src/main/java/com/wei/picquest/navigation/TopLevelDestination.kt @@ -0,0 +1,36 @@ +package com.wei.picquest.navigation + +import androidx.compose.ui.graphics.vector.ImageVector +import com.wei.picquest.R +import com.wei.picquest.core.designsystem.icon.PqIcons + +/** + * Type for the top level destinations in the application. Each of these destinations + * can contain one or more screens (based on the window size). Navigation from one screen to the + * next within a single destination will be handled directly in composables. + */ +enum class TopLevelDestination( + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val iconTextId: Int, + val titleTextId: Int, +) { + HOME( + selectedIcon = PqIcons.Home, + unselectedIcon = PqIcons.HomeBorder, + iconTextId = R.string.home, + titleTextId = R.string.home, + ), + PHOTO_LIBRARY( + selectedIcon = PqIcons.PhotoLibrary, + unselectedIcon = PqIcons.PhotoLibraryBorder, + iconTextId = R.string.photo_library, + titleTextId = R.string.photo_library, + ), + CONTACT_ME( + selectedIcon = PqIcons.ContactMe, + unselectedIcon = PqIcons.ContactMeBorder, + iconTextId = R.string.contact_me, + titleTextId = R.string.contact_me, + ), +} diff --git a/app/src/main/java/com/wei/picquest/ui/PqApp.kt b/app/src/main/java/com/wei/picquest/ui/PqApp.kt new file mode 100644 index 0000000..23bd43f --- /dev/null +++ b/app/src/main/java/com/wei/picquest/ui/PqApp.kt @@ -0,0 +1,331 @@ +package com.wei.picquest.ui + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.window.layout.DisplayFeature +import com.wei.picquest.R +import com.wei.picquest.core.data.utils.NetworkMonitor +import com.wei.picquest.core.designsystem.component.FunctionalityNotAvailablePopup +import com.wei.picquest.core.designsystem.component.PqAppSnackbar +import com.wei.picquest.core.designsystem.component.PqBackground +import com.wei.picquest.core.designsystem.component.PqNavigationBar +import com.wei.picquest.core.designsystem.component.PqNavigationBarItem +import com.wei.picquest.core.designsystem.component.PqNavigationDrawer +import com.wei.picquest.core.designsystem.component.PqNavigationDrawerItem +import com.wei.picquest.core.designsystem.component.PqNavigationRail +import com.wei.picquest.core.designsystem.component.PqNavigationRailItem +import com.wei.picquest.core.designsystem.theme.SPACING_LARGE +import com.wei.picquest.core.designsystem.ui.PqNavigationType +import com.wei.picquest.core.manager.ErrorTextPrefix +import com.wei.picquest.core.manager.Message +import com.wei.picquest.core.manager.SnackbarManager +import com.wei.picquest.core.manager.SnackbarState +import com.wei.picquest.core.utils.UiText +import com.wei.picquest.navigation.PqNavHost +import com.wei.picquest.navigation.TopLevelDestination + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class, + ExperimentalComposeUiApi::class, +) +@Composable +fun PqApp( + networkMonitor: NetworkMonitor, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + appState: PqAppState = rememberPqAppState( + networkMonitor = networkMonitor, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + ), + snackbarManager: SnackbarManager, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + val pqBottomBar = stringResource(R.string.tag_pq_bottom_bar) + val pqNavRail = stringResource(R.string.tag_pq_nav_rail) + val pqNavDrawer = stringResource(R.string.tag_pq_nav_drawer) + + if (appState.showFunctionalityNotAvailablePopup.value) { + FunctionalityNotAvailablePopup( + onDismiss = { + appState.showFunctionalityNotAvailablePopup.value = false + }, + ) + } + + val isOffline by appState.isOffline.collectAsStateWithLifecycle() + + // If user is not connected to the internet show a snack bar to inform them. + val notConnectedMessage = stringResource(R.string.not_connected) + LaunchedEffect(isOffline) { + if (isOffline) { + snackbarManager.showMessage( + state = SnackbarState.Error, + uiText = UiText.DynamicString(notConnectedMessage), + ) + } + } + + LaunchedEffect(key1 = snackbarHostState) { + collectAndShowSnackbar(snackbarManager, snackbarHostState, context) + } + PqBackground { + Scaffold( + modifier = Modifier.semantics { + testTagsAsResourceId = true + }, + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { snackbarData -> + if (!appState.isFullScreenCurrentDestination) { + val isError = snackbarData.visuals.message.startsWith(ErrorTextPrefix) + PqAppSnackbar(snackbarData, isError) + } + }, + ) + }, + bottomBar = { + if (!appState.isFullScreenCurrentDestination && + appState.navigationType == PqNavigationType.BOTTOM_NAVIGATION + ) { + PqBottomBar( + destinations = appState.topLevelDestinations, + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + modifier = Modifier.testTag(pqBottomBar), + ) + } + }, + ) { padding -> + Row( + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, + ), + ), + ) { + if (!appState.isFullScreenCurrentDestination && + appState.navigationType == PqNavigationType.PERMANENT_NAVIGATION_DRAWER + ) { + PqNavDrawer( + destinations = appState.topLevelDestinations, + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + modifier = Modifier + .testTag(pqNavDrawer) + .padding(SPACING_LARGE.dp) + .safeDrawingPadding(), + ) + } + + if (!appState.isFullScreenCurrentDestination && + appState.navigationType == PqNavigationType.NAVIGATION_RAIL + ) { + PqNavRail( + destinations = appState.topLevelDestinations, + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + modifier = Modifier + .testTag(pqNavRail) + .safeDrawingPadding(), + ) + } + + Column( + modifier = Modifier + .fillMaxSize(), + ) { + PqNavHost( + modifier = Modifier.fillMaxSize(), + appState = appState, + displayFeatures = displayFeatures, + ) + } + } + } + } +} + +@Composable +private fun PqNavDrawer( + destinations: List, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + PqNavigationDrawer(modifier = modifier) { + destinations.forEach { destination -> + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + PqNavigationDrawerItem( + modifier = Modifier, + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = stringResource(destination.iconTextId), + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = stringResource(destination.iconTextId), + ) + }, + ) { + Text( + text = stringResource(id = destination.iconTextId), + ) + } + } + } +} + +@Composable +private fun PqNavRail( + destinations: List, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + PqNavigationRail(modifier = modifier) { + destinations.forEach { destination -> + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + PqNavigationRailItem( + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = stringResource(destination.iconTextId), + ) + }, + modifier = Modifier, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = stringResource(destination.iconTextId), + ) + }, + ) + } + } +} + +@Composable +private fun PqBottomBar( + destinations: List, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + PqNavigationBar( + modifier = modifier, + ) { + destinations.forEach { destination -> + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + PqNavigationBarItem( + modifier = Modifier, + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = stringResource(id = destination.iconTextId), + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = stringResource(id = destination.iconTextId), + ) + }, + label = { Text(stringResource(destination.iconTextId)) }, + ) + } + } +} + +private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = + this?.hierarchy?.any { + it.route?.contains(destination.name, true) ?: false + } ?: false + +suspend fun collectAndShowSnackbar( + snackbarManager: SnackbarManager, + snackbarHostState: SnackbarHostState, + context: Context, +) { + snackbarManager.messages.collect { messages -> + if (messages.isNotEmpty()) { + val message = messages.first() + val text = getMessageText(message, context) + + if (message.state == SnackbarState.Error) { + snackbarHostState.showSnackbar( + message = ErrorTextPrefix + text, + ) + } else { + snackbarHostState.showSnackbar(message = text) + } + snackbarManager.setMessageShown(message.id) + } + } +} + +fun getMessageText(message: Message, context: Context): String { + return when (message.uiText) { + is UiText.DynamicString -> (message.uiText as UiText.DynamicString).value + is UiText.StringResource -> context.getString( + (message.uiText as UiText.StringResource).resId, + *(message.uiText as UiText.StringResource).args.map { it.toString(context) } + .toTypedArray(), + ) + } +} diff --git a/app/src/main/java/com/wei/picquest/ui/PqAppState.kt b/app/src/main/java/com/wei/picquest/ui/PqAppState.kt new file mode 100644 index 0000000..9ce1550 --- /dev/null +++ b/app/src/main/java/com/wei/picquest/ui/PqAppState.kt @@ -0,0 +1,211 @@ +package com.wei.picquest.ui + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.tracing.trace +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import com.wei.picquest.core.data.utils.NetworkMonitor +import com.wei.picquest.core.designsystem.ui.DeviceOrientation +import com.wei.picquest.core.designsystem.ui.DevicePosture +import com.wei.picquest.core.designsystem.ui.PqContentType +import com.wei.picquest.core.designsystem.ui.PqNavigationType +import com.wei.picquest.core.designsystem.ui.currentDeviceOrientation +import com.wei.picquest.core.designsystem.ui.isBookPosture +import com.wei.picquest.core.designsystem.ui.isSeparating +import com.wei.picquest.feature.home.home.navigation.homeRoute +import com.wei.picquest.feature.home.home.navigation.navigateToHome +import com.wei.picquest.navigation.TopLevelDestination +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@Composable +fun rememberPqAppState( + windowSizeClass: WindowSizeClass, + networkMonitor: NetworkMonitor, + displayFeatures: List, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + navController: NavHostController = rememberNavController(), +): PqAppState { + return remember( + navController, + coroutineScope, + windowSizeClass, + networkMonitor, + displayFeatures, + ) { + PqAppState( + navController, + coroutineScope, + windowSizeClass, + networkMonitor, + displayFeatures, + ) + } +} + +@Stable +class PqAppState( + val navController: NavHostController, + val coroutineScope: CoroutineScope, + val windowSizeClass: WindowSizeClass, + networkMonitor: NetworkMonitor, + displayFeatures: List, +) { + val currentDeviceOrientation: DeviceOrientation + @Composable get() = currentDeviceOrientation() + + /** + * We are using display's folding features to map the device postures a fold is in. + * In the state of folding device If it's half fold in BookPosture we want to avoid content + * at the crease/hinge + */ + val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() + + val foldingDevicePosture = when { + isBookPosture(foldingFeature) -> + DevicePosture.BookPosture(foldingFeature.bounds) + + isSeparating(foldingFeature) -> + DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation) + + else -> DevicePosture.NormalPosture + } + + /** + * This will help us select type of navigation and content type depending on window size and + * fold state of the device. + */ + val navigationType: PqNavigationType + @Composable get() = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> { + PqNavigationType.BOTTOM_NAVIGATION + } + + WindowWidthSizeClass.Medium -> { + PqNavigationType.NAVIGATION_RAIL + } + + WindowWidthSizeClass.Expanded -> { + if (foldingDevicePosture is DevicePosture.BookPosture) { + PqNavigationType.NAVIGATION_RAIL + } else { + PqNavigationType.PERMANENT_NAVIGATION_DRAWER + } + } + + else -> { + PqNavigationType.BOTTOM_NAVIGATION + } + } + + val contentType: PqContentType + @Composable get() = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> { + PqContentType.SINGLE_PANE + } + + WindowWidthSizeClass.Medium -> { + if (foldingDevicePosture != DevicePosture.NormalPosture) { + PqContentType.DUAL_PANE + } else { + PqContentType.SINGLE_PANE + } + } + + WindowWidthSizeClass.Expanded -> { + PqContentType.DUAL_PANE + } + + else -> { + PqContentType.SINGLE_PANE + } + } + + val currentDestination: NavDestination? + @Composable get() = navController + .currentBackStackEntryAsState().value?.destination + + val isFullScreenCurrentDestination: Boolean + @Composable get() = when (currentDestination?.route) { + null -> true + else -> false + } + + val currentTopLevelDestination: TopLevelDestination? + @Composable get() = when (currentDestination?.route) { + homeRoute -> TopLevelDestination.HOME + else -> null + } + + val isOffline = networkMonitor.isOnline + .map(Boolean::not) + .stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) + + val showFunctionalityNotAvailablePopup: MutableState = mutableStateOf(false) + + /** + * Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the + * route. + */ + val topLevelDestinations: List = TopLevelDestination.values().asList() + + /** + * UI logic for navigating to a top level destination in the app. Top level destinations have + * only one copy of the destination of the back stack, and save and restore state whenever you + * navigate to and from it. + * + * @param topLevelDestination: The destination the app needs to navigate to. + */ + fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) { + trace("Navigation: ${topLevelDestination.name}") { + val topLevelNavOptions = navOptions { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + + when (topLevelDestination) { + TopLevelDestination.HOME -> navController.navigateToHome( + topLevelNavOptions, + ) + +// TopLevelDestination.PHOTO_LIBRARY -> navController.navigateToPhotoLibrary( +// topLevelNavOptions, +// ) +// +// TopLevelDestination.CONTACT_ME -> navController.navigateToContactMe( +// topLevelNavOptions, +// ) + + else -> showFunctionalityNotAvailablePopup.value = true + } + } + } +} diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..ec1279b --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,8 @@ + + + PicQuest + ⚠️ 您沒有網路連線 + 照片庫 + 首頁 + 聯絡我 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 901b16b..503bf20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,12 @@ PicQuest + ⚠️ You aren’t connected to the internet + Photo Library + Home + Contact Me + + + PqBottomBar + PqNavRail + PqNavDrawer \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 58046e0..af9727a 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,20 +1,20 @@ - + - - + - + - \ No newline at end of file + \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index c652ee5..ee89e3f 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -18,7 +18,7 @@ class AndroidFeatureConventionPlugin : Plugin { extensions.configure { defaultConfig { testInstrumentationRunner = - "com.wei.picquest.core.testing.AtTestRunner" + "com.wei.picquest.core.testing.PqTestRunner" } configureGradleManagedDevices(this) } diff --git a/core/common/src/main/java/com/wei/picquest/core/manager/SnackbarManager.kt b/core/common/src/main/java/com/wei/picquest/core/manager/SnackbarManager.kt new file mode 100644 index 0000000..b3c1299 --- /dev/null +++ b/core/common/src/main/java/com/wei/picquest/core/manager/SnackbarManager.kt @@ -0,0 +1,49 @@ +package com.wei.picquest.core.manager + +import com.wei.picquest.core.utils.UiText +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +const val ErrorTextPrefix = "Error:" + +enum class SnackbarState { + Default, + Error, +} + +data class Message(val id: Long, val state: SnackbarState, val uiText: UiText) + +/** + * Class responsible for managing Snackbar messages to show on the screen + */ +@Singleton +class SnackbarManager @Inject constructor() { + + private val _messages: MutableStateFlow> = MutableStateFlow(emptyList()) + val messages: StateFlow> get() = _messages.asStateFlow() + + fun showMessage(state: SnackbarState, uiText: UiText) { + _messages.update { currentMessages -> + currentMessages + Message( + id = UUID.randomUUID().mostSignificantBits, + state = state, + uiText = uiText, + ) + } + } + + fun setMessageShown(messageId: Long) { + _messages.update { currentMessages -> + currentMessages.filterNot { it.id == messageId } + } + } + + fun getLastMessage(): Message? { + return _messages.value.lastOrNull() + } +} diff --git a/core/common/src/main/java/com/wei/picquest/core/utils/UiText.kt b/core/common/src/main/java/com/wei/picquest/core/utils/UiText.kt new file mode 100644 index 0000000..a491fdc --- /dev/null +++ b/core/common/src/main/java/com/wei/picquest/core/utils/UiText.kt @@ -0,0 +1,69 @@ +package com.wei.picquest.core.utils + +import android.content.Context +import androidx.annotation.StringRes + +/** + * UiText 是一個密封接口,用來封裝不同形式的文字內容。 + */ +sealed class UiText { + + /** + * DynamicString 是 UiText 的一種形式,表示可以動態變化的文字。 + * @param value 文字的內容。 + */ + data class DynamicString(val value: String) : UiText() + + /** + * StringResource 是 UiText 的一種形式,表示來自 Android 資源的文字。 + * @param resId 文字資源的 ID。 + * @param args 文字資源的參數列表。 + */ + data class StringResource( + @StringRes val resId: Int, + val args: List = emptyList(), + ) : UiText() { + + /** + * Args 是 StringResource 參數的密封接口,可以是動態字串或 UiText 類型。 + */ + sealed class Args { + + /** + * DynamicString 是 Args 的一種形式,表示可以動態變化的文字。 + * @param value 文字的內容。 + */ + data class DynamicString(val value: String) : Args() + + /** + * UiTextArg 是 Args 的一種形式,表示其他的 UiText 類型。 + * @param uiText 被封裝的 UiText 物件。 + */ + data class UiTextArg(val uiText: UiText) : Args() + + /** + * toString 方法將 Args 物件轉換為字串。 + * @param context Android 上下文物件。 + */ + fun toString(context: Context) = + when (this) { + is DynamicString -> value + is UiTextArg -> uiText.asString(context) + } + } + } + + /** + * asString 方法將 UiText 物件轉換為字串。 + * @param context Android 上下文物件。 + */ + fun asString(context: Context): String = + when (this) { + is DynamicString -> value + is StringResource -> + context.getString( + resId, + *(args.map { it.toString(context) }.toTypedArray()), + ) + } +} diff --git a/core/data/src/main/java/com/wei/picquest/core/data/di/DataModule.kt b/core/data/src/main/java/com/wei/picquest/core/data/di/DataModule.kt new file mode 100644 index 0000000..f1dec62 --- /dev/null +++ b/core/data/src/main/java/com/wei/picquest/core/data/di/DataModule.kt @@ -0,0 +1,18 @@ +package com.wei.picquest.core.data.di + +import com.wei.picquest.core.data.utils.ConnectivityManagerNetworkMonitor +import com.wei.picquest.core.data.utils.NetworkMonitor +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DataModule { + + @Binds + fun bindsNetworkMonitor( + networkMonitor: ConnectivityManagerNetworkMonitor, + ): NetworkMonitor +} diff --git a/core/data/src/main/java/com/wei/picquest/core/data/utils/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/java/com/wei/picquest/core/data/utils/ConnectivityManagerNetworkMonitor.kt new file mode 100644 index 0000000..ab0799a --- /dev/null +++ b/core/data/src/main/java/com/wei/picquest/core/data/utils/ConnectivityManagerNetworkMonitor.kt @@ -0,0 +1,75 @@ +package com.wei.picquest.core.data.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.NetworkRequest.Builder +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import javax.inject.Inject + +class ConnectivityManagerNetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context, +) : NetworkMonitor { + override val isOnline: Flow = callbackFlow { + val connectivityManager = context.getSystemService() + if (connectivityManager == null) { + channel.trySend(false) + channel.close() + return@callbackFlow + } + + /** + * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest], + * not just the active network. So we can simply track the presence (or absence) of such [Network]. + */ + val callback = object : NetworkCallback() { + + private val networks = mutableSetOf() + + override fun onAvailable(network: Network) { + networks += network + channel.trySend(true) + } + + override fun onLost(network: Network) { + networks -= network + channel.trySend(networks.isNotEmpty()) + } + } + + val request = Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + + /** + * Sends the latest connectivity status to the underlying channel. + */ + channel.trySend(connectivityManager.isCurrentlyConnected()) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + } + .conflate() + + @Suppress("DEPRECATION") + private fun ConnectivityManager.isCurrentlyConnected() = when { + VERSION.SDK_INT >= VERSION_CODES.M -> + activeNetwork + ?.let(::getNetworkCapabilities) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + + else -> activeNetworkInfo?.isConnected + } ?: false +} diff --git a/core/data/src/main/java/com/wei/picquest/core/data/utils/NetworkMonitor.kt b/core/data/src/main/java/com/wei/picquest/core/data/utils/NetworkMonitor.kt new file mode 100644 index 0000000..b9f363c --- /dev/null +++ b/core/data/src/main/java/com/wei/picquest/core/data/utils/NetworkMonitor.kt @@ -0,0 +1,10 @@ +package com.wei.picquest.core.data.utils + +import kotlinx.coroutines.flow.Flow + +/** + * Utility for reporting app connectivity status + */ +interface NetworkMonitor { + val isOnline: Flow +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Background.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Background.kt new file mode 100644 index 0000000..bc9d878 --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Background.kt @@ -0,0 +1,56 @@ +package com.wei.picquest.core.designsystem.component + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.wei.picquest.core.designsystem.theme.LocalBackgroundTheme +import com.wei.picquest.core.designsystem.theme.PqTheme + +/** + * The main background for the app. + * Uses [LocalBackgroundTheme] to set the color and tonal elevation of a [Surface]. + * + * @param modifier Modifier to be applied to the background. + * @param content The background content. + */ +@Composable +fun PqBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val color = LocalBackgroundTheme.current.color + val tonalElevation = LocalBackgroundTheme.current.tonalElevation + Surface( + color = color, + tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation, + modifier = modifier.fillMaxSize(), + ) { + CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) { + content() + } + } +} + +/** + * Multipreview annotation that represents light and dark themes. Add this annotation to a + * composable to render the both themes. + */ +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +annotation class ThemePreviews + +@ThemePreviews +@Composable +fun BackgroundDefault() { + PqTheme() { + PqBackground(Modifier.size(100.dp), content = {}) + } +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/BaseLineHeightModifier.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/BaseLineHeightModifier.kt new file mode 100644 index 0000000..ef63839 --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/BaseLineHeightModifier.kt @@ -0,0 +1,47 @@ +package com.wei.picquest.core.designsystem.component + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp + +/** + * Applied to a Text, it sets the distance between the top and the first baseline. It + * also makes the bottom of the element coincide with the last baseline of the text. + * + * _______________ + * | | ↑ + * | | | heightFromBaseline + * |Hello, World!| ↓ + * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + * + * This modifier can be used to distribute multiple text elements using a certain distance between + * baselines. + */ +data class BaselineHeightModifier( + val heightFromBaseline: Dp, +) : LayoutModifier { + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val textPlaceable = measurable.measure(constraints) + val firstBaseline = textPlaceable[FirstBaseline] + val lastBaseline = textPlaceable[LastBaseline] + + val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline + return layout(constraints.maxWidth, height) { + val topY = heightFromBaseline.roundToPx() - firstBaseline + textPlaceable.place(0, topY) + } + } +} + +fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = + this.then(BaselineHeightModifier(heightFromBaseline)) diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/ImageLoaders.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/ImageLoaders.kt new file mode 100644 index 0000000..c909346 --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/ImageLoaders.kt @@ -0,0 +1,36 @@ +package com.wei.picquest.core.designsystem.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil.ImageLoader +import coil.compose.rememberAsyncImagePainter +import coil.decode.SvgDecoder +import coil.request.ImageRequest + +/** + * Returns a Painter for an image resource. Uses Coil for image loading, with support for SVG. + * @param resId The resource ID of the image to load. + * @param isPreview Whether to use a preview (simple resource loading) or full Coil loading. + * @return A Painter object for the image. + */ +@Composable +fun coilImagePainter(resId: Int, isPreview: Boolean = false): Painter { + val context = LocalContext.current + val imageLoader = ImageLoader.Builder(context) + .components { + add(SvgDecoder.Factory()) + } + .build() + + val request = ImageRequest.Builder(context) + .data(resId) + .build() + + return if (isPreview) { + painterResource(id = resId) + } else { + rememberAsyncImagePainter(request, imageLoader) + } +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Navigation.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Navigation.kt new file mode 100644 index 0000000..fe4cf33 --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Navigation.kt @@ -0,0 +1,348 @@ +package com.wei.picquest.core.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.PermanentDrawerSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.wei.picquest.core.designsystem.icon.PqIcons +import com.wei.picquest.core.designsystem.theme.PqTheme +import com.wei.picquest.core.designsystem.theme.SPACING_LARGE + +/** + * PicQuest navigation bar item with icon and label content slots. Wraps Material 3 + * [NavigationBarItem]. + * + * @param modifier Modifier to be applied to this item. + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param selectedIcon The item icon content when selected. + * @param enabled controls the enabled state of this item. When `false`, this item will not be + * clickable and will appear disabled to accessibility services. + * @param label The item text label content. + * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will + * only be shown when this item is selected. + */ +@Composable +fun RowScope.PqNavigationBarItem( + modifier: Modifier = Modifier, + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, +) { + NavigationBarItem( + modifier = modifier, + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = PqNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = PqNavigationDefaults.navigationContentColor(), + selectedTextColor = PqNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = PqNavigationDefaults.navigationContentColor(), + indicatorColor = PqNavigationDefaults.navigationIndicatorColor(), + ), + ) +} + +/** + * PicQuest navigation bar with content slot. Wraps Material 3 [NavigationBar]. + * + * @param modifier Modifier to be applied to the navigation bar. + * @param content Destinations inside the navigation bar. This should contain multiple + * [NavigationBarItem]s. + */ +@Composable +fun PqNavigationBar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + NavigationBar( + modifier = modifier, + contentColor = PqNavigationDefaults.navigationContentColor(), + tonalElevation = 0.dp, + content = content, + ) +} + +/** + * PicQuest navigation rail item with icon and label content slots. Wraps Material 3 + * [NavigationRailItem]. + * + * @param modifier Modifier to be applied to this item. + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param selectedIcon The item icon content when selected. + * @param enabled controls the enabled state of this item. When `false`, this item will not be + * clickable and will appear disabled to accessibility services. + * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will + * only be shown when this item is selected. + */ +@Composable +fun PqNavigationRailItem( + modifier: Modifier = Modifier, + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + enabled: Boolean = true, + alwaysShowLabel: Boolean = true, +) { + NavigationRailItem( + modifier = modifier, + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + enabled = enabled, + label = null, + alwaysShowLabel = alwaysShowLabel, + colors = NavigationRailItemDefaults.colors( + selectedIconColor = PqNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = PqNavigationDefaults.navigationContentColor(), + selectedTextColor = PqNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = PqNavigationDefaults.navigationContentColor(), + indicatorColor = PqNavigationDefaults.navigationIndicatorColor(), + ), + ) +} + +/** + * PicQuest navigation rail with header and content slots. Wraps Material 3 [NavigationRail]. + * + * @param modifier Modifier to be applied to the navigation rail. + * @param header Optional header that may hold a floating action button or a logo. + * @param content Destinations inside the navigation rail. This should contain multiple + * [NavigationRailItem]s. + */ +@Composable +fun PqNavigationRail( + modifier: Modifier = Modifier, + header: @Composable (ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + NavigationRail( + modifier = modifier, + containerColor = Color.Transparent, + contentColor = PqNavigationDefaults.navigationContentColor(), + header = header, + content = { + Column( + modifier = Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + content() + } + }, + ) +} + +/** + * PicQuest navigation drawer item with icon and label content slots. Wraps Material 3 + * [NavigationDrawerItem]. + * + * @param modifier Modifier to be applied to this item. + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is selected. + * @param icon The item icon content. + * @param selectedIcon The item icon content when selected. + * @param label The item text label content. + */ +@Composable +fun PqNavigationDrawerItem( + modifier: Modifier = Modifier, + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + selectedIcon: @Composable () -> Unit = icon, + label: @Composable () -> Unit, +) { + NavigationDrawerItem( + modifier = modifier, + selected = selected, + onClick = onClick, + icon = if (selected) selectedIcon else icon, + label = { + Box(modifier = Modifier.padding(horizontal = SPACING_LARGE.dp)) { + label() + } + }, + colors = NavigationDrawerItemDefaults.colors( + selectedIconColor = PqNavigationDefaults.navigationSelectedItemColor(), + unselectedIconColor = PqNavigationDefaults.navigationContentColor(), + selectedTextColor = PqNavigationDefaults.navigationSelectedItemColor(), + unselectedTextColor = PqNavigationDefaults.navigationContentColor(), + ), + ) +} + +/** + * PicQuest navigation drawer with content slot. Wraps Material 3 [PermanentDrawerSheet]. + * + * @param modifier Modifier to be applied to the navigation drawer. + * @param content Destinations inside the navigation drawer. This should contain multiple + * [NavigationDrawerItem]s. + */ +@Composable +fun PqNavigationDrawer( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + // TODO check on custom width of PermanentNavigationDrawer: b/232495216 + PermanentDrawerSheet( + modifier = modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp), + drawerContainerColor = Color.Transparent, + drawerContentColor = PqNavigationDefaults.navigationContentColor(), + content = { + Column( + modifier = Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + content() + } + }, + ) +} + +@ThemePreviews +@Composable +fun PqNavigationBarPreview() { + PqTheme { + PqNavigationBar { + previewItems.forEachIndexed { index, item -> + PqNavigationBarItem( + selected = index == 0, + onClick = { }, + icon = { + Icon( + imageVector = previewIcons[index], + contentDescription = item, + ) + }, + selectedIcon = { + Icon( + imageVector = previewSelectedIcons[index], + contentDescription = item, + ) + }, + label = { Text(item) }, + ) + } + } + } +} + +@ThemePreviews +@Composable +fun PqNavigationRailPreview() { + PqTheme { + PqBackground { + PqNavigationRail { + previewItems.forEachIndexed { index, item -> + PqNavigationRailItem( + selected = index == 0, + onClick = { }, + icon = { + Icon( + imageVector = previewIcons[index], + contentDescription = item, + ) + }, + selectedIcon = { + Icon( + imageVector = previewSelectedIcons[index], + contentDescription = item, + ) + }, + ) + } + } + } + } +} + +@ThemePreviews +@Composable +fun PqNavigationDrawerPreview() { + PqTheme { + PqBackground { + PqNavigationDrawer { + previewItems.forEachIndexed { index, item -> + PqNavigationDrawerItem( + selected = index == 0, + onClick = { }, + icon = { + Icon( + imageVector = previewIcons[index], + contentDescription = item, + ) + }, + selectedIcon = { + Icon( + imageVector = previewSelectedIcons[index], + contentDescription = item, + ) + }, + label = { Text(item) }, + ) + } + } + } + } +} + +/** + * PicQuest navigation default values. + */ +object PqNavigationDefaults { + @Composable + fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant + + @Composable + fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer + + @Composable + fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer +} + +internal val previewItems = listOf("Schedule", "Home", "Contact Me") +internal val previewIcons = listOf( + PqIcons.ScheduleBorder, + PqIcons.HomeBorder, + PqIcons.ContactMeBorder, +) +internal val previewSelectedIcons = listOf( + PqIcons.Schedule, + PqIcons.Home, + PqIcons.ContactMe, +) diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Snackbar.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Snackbar.kt new file mode 100644 index 0000000..7c04ae3 --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/Snackbar.kt @@ -0,0 +1,104 @@ +package com.wei.picquest.core.designsystem.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape + +/** + * PqAppSnackbar 是一個 Composable function,根據是否為錯誤來顯示不同風格的 Snackbar。 + * @param snackbarData Snackbar的數據。 + * @param isError 是否為錯誤。 + * @param modifier 修改器,用於修改 Composable function 的屬性。 + * @param actionOnNewLine 是否將操作按鈕換行顯示。 + * @param shape Snackbar 的形狀。 + */ +@Composable +fun PqAppSnackbar( + snackbarData: SnackbarData, + isError: Boolean, + modifier: Modifier = Modifier, + actionOnNewLine: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, +) { + if (isError) { + PqErrorSnackbar( + snackbarData = snackbarData, + modifier = modifier, + actionOnNewLine = actionOnNewLine, + shape = shape, + ) + } else { + PqSnackbar( + snackbarData = snackbarData, + modifier = modifier, + actionOnNewLine = actionOnNewLine, + shape = shape, + ) + } +} + +/** + * PqErrorSnackbar 是一個 Composable function,用於顯示錯誤風格的 Snackbar。 + * @param snackbarData Snackbar的數據。 + * @param modifier 修改器,用於修改 Composable function 的屬性。 + * @param actionOnNewLine 是否將操作按鈕換行顯示。 + * @param shape Snackbar 的形狀。 + * @param backgroundColor Snackbar 的背景顏色。 + * @param contentColor Snackbar 的內容顏色。 + * @param actionColor Snackbar 的操作按鈕顏色。 + */ +@Composable +private fun PqErrorSnackbar( + snackbarData: SnackbarData, + modifier: Modifier = Modifier, + actionOnNewLine: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, + backgroundColor: Color = MaterialTheme.colorScheme.errorContainer, + contentColor: Color = MaterialTheme.colorScheme.onErrorContainer, + actionColor: Color = MaterialTheme.colorScheme.onErrorContainer, +) { + Snackbar( + snackbarData = snackbarData, + modifier = modifier, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = backgroundColor, + contentColor = contentColor, + actionColor = actionColor, + ) +} + +/** + * PqSnackbar 是一個 Composable function,用於顯示普通風格的 Snackbar。 + * @param snackbarData Snackbar的數據。 + * @param modifier 修改器,用於修改 Composable function 的屬性。 + * @param actionOnNewLine 是否將操作按鈕換行顯示。 + * @param shape Snackbar 的形狀。 + * @param backgroundColor Snackbar 的背景顏色。 + * @param contentColor Snackbar 的內容顏色。 + * @param actionColor Snackbar 的操作按鈕顏色。 + */ +@Composable +private fun PqSnackbar( + snackbarData: SnackbarData, + modifier: Modifier = Modifier, + actionOnNewLine: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + actionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Snackbar( + snackbarData = snackbarData, + modifier = modifier, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = backgroundColor, + contentColor = contentColor, + actionColor = actionColor, + ) +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/UiExtras.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/UiExtras.kt new file mode 100644 index 0000000..09c6f02 --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/component/UiExtras.kt @@ -0,0 +1,36 @@ +package com.wei.picquest.core.designsystem.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.wei.picquest.core.designsystem.R + +@Composable +fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + text = { + val functionalityNotAvailable = stringResource(R.string.functionality_not_available) + Text( + text = functionalityNotAvailable, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.semantics { contentDescription = functionalityNotAvailable }, + ) + }, + confirmButton = { + val close = stringResource(R.string.close) + TextButton(onClick = onDismiss) { + Text( + text = close, + modifier = Modifier.semantics { contentDescription = close }, + ) + } + }, + ) +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt new file mode 100644 index 0000000..624842e --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/icon/PqIcons.kt @@ -0,0 +1,58 @@ +package com.wei.picquest.core.designsystem.icon + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material.icons.outlined.SupportAgent +import androidx.compose.material.icons.outlined.Upcoming +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material.icons.rounded.ArrowForward +import androidx.compose.material.icons.rounded.ArrowForwardIos +import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Phone +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.SupportAgent +import androidx.compose.material.icons.rounded.Upcoming +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * PicQuest icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs. + */ +object PqIcons { + val ArrowBackIosNew = Icons.Rounded.ArrowBackIosNew + val ArrowForwardIos = Icons.Rounded.ArrowForwardIos + val ArrowBack = Icons.Rounded.ArrowBack + val ArrowForward = Icons.Rounded.ArrowForward + val Close = Icons.Rounded.Close + val Search = Icons.Rounded.Search + val Settings = Icons.Rounded.Settings + val Info = Icons.Rounded.Info + val InfoBorder = Icons.Outlined.Info + val Home = Icons.Rounded.Home + val HomeBorder = Icons.Outlined.Home + val Schedule = Icons.Rounded.CalendarMonth + val ScheduleBorder = Icons.Outlined.CalendarMonth + val ContactMe = Icons.Rounded.SupportAgent + val ContactMeBorder = Icons.Outlined.SupportAgent + val PhotoLibrary = Icons.Rounded.PhotoLibrary + val PhotoLibraryBorder = Icons.Outlined.PhotoLibrary + val Phone = Icons.Rounded.Phone + val Upcoming = Icons.Rounded.Upcoming + val UpcomingBorder = Icons.Outlined.Upcoming + val Star = Icons.Rounded.Star + val Person = Icons.Rounded.Person + val Menu = Icons.Rounded.Menu + val Add = Icons.Rounded.Add +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/theme/Type.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/theme/Type.kt index b9a4bb6..fc3b59f 100644 --- a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/theme/Type.kt +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/theme/Type.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.wei.picquest.core.designsystem.R -val atFontFamily = FontFamily( +val pqFontFamily = FontFamily( Font(R.font.gilroy_black, FontWeight.Black), Font(R.font.gilroy_black_italic, FontWeight.Black, FontStyle.Italic), Font(R.font.gilroy_bold, FontWeight.Bold), @@ -43,105 +43,105 @@ val abrilFatfaceFontFamily = FontFamily( fun getAppTypography(): Typography { return Typography( displayLarge = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp, ), displayMedium = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp, ), displaySmall = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp, ), headlineLarge = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp, ), headlineMedium = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp, ), headlineSmall = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp, ), titleLarge = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp, ), titleMedium = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp, ), titleSmall = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, ), bodyLarge = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp, ), bodyMedium = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp, ), bodySmall = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp, ), labelLarge = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, ), labelMedium = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, ), labelSmall = TextStyle( - fontFamily = atFontFamily, + fontFamily = pqFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 16.sp, diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DeviceOrientation.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DeviceOrientation.kt new file mode 100644 index 0000000..0e4f47e --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DeviceOrientation.kt @@ -0,0 +1,20 @@ +package com.wei.picquest.core.designsystem.ui + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration + +enum class DeviceOrientation { + PORTRAIT, + LANDSCAPE, +} + +@Composable +fun currentDeviceOrientation(): DeviceOrientation { + val configuration = LocalConfiguration.current + return if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + DeviceOrientation.PORTRAIT + } else { + DeviceOrientation.LANDSCAPE + } +} diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DevicePreviews.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DevicePreviews.kt new file mode 100644 index 0000000..0a67fad --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/DevicePreviews.kt @@ -0,0 +1,42 @@ +package com.wei.picquest.core.designsystem.ui + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +/** + * Multipreview annotation that represents various device sizes. Add this annotation to a composable + * to render various devices. + */ +@Preview( + name = "phone", + device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480", + uiMode = Configuration.ORIENTATION_PORTRAIT, +) +@Preview( + name = "foldable", + device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480", + uiMode = Configuration.ORIENTATION_PORTRAIT, +) +@Preview( + name = "tablet", + device = "spec:shape=Normal,width=840,height=1280,unit=dp,dpi=480", + uiMode = Configuration.ORIENTATION_PORTRAIT, +) +annotation class DevicePortraitPreviews + +@Preview( + name = "phone", + device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480", + uiMode = Configuration.ORIENTATION_LANDSCAPE, +) +@Preview( + name = "foldable", + device = "spec:shape=Normal,width=841,height=673,unit=dp,dpi=480", + uiMode = Configuration.ORIENTATION_LANDSCAPE, +) +@Preview( + name = "tablet", + device = "spec:shape=Normal,width=1280,height=840,unit=dp,dpi=480", + uiMode = Configuration.ORIENTATION_LANDSCAPE, +) +annotation class DeviceLandscapePreviews diff --git a/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/WindowStateUtils.kt b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/WindowStateUtils.kt new file mode 100644 index 0000000..d6a4c1c --- /dev/null +++ b/core/designsystem/src/main/java/com/wei/picquest/core/designsystem/ui/WindowStateUtils.kt @@ -0,0 +1,67 @@ +package com.wei.picquest.core.designsystem.ui + +import android.graphics.Rect +import androidx.window.layout.FoldingFeature +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +/** + * Information about the posture of the device + */ +sealed interface DevicePosture { + object NormalPosture : DevicePosture + + data class TableTopPosture( + val hingePosition: Rect, + ) : DevicePosture + + data class BookPosture( + val hingePosition: Rect, + ) : DevicePosture + + data class Separating( + val hingePosition: Rect, + var orientation: FoldingFeature.Orientation, + ) : DevicePosture +} + +@OptIn(ExperimentalContracts::class) +fun isTableTopPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL +} + +@OptIn(ExperimentalContracts::class) +fun isBookPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.VERTICAL +} + +@OptIn(ExperimentalContracts::class) +fun isSeparating(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating +} + +/** + * Different type of navigation supported by app depending on device size and state. + */ +enum class PqNavigationType { + BOTTOM_NAVIGATION, NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER +} + +/** + * Different position of navigation content inside Navigation Rail, Navigation Drawer depending on device size and state. + */ +enum class PqNavigationContentPosition { + TOP, CENTER +} + +/** + * App Content shown depending on device size and state. + */ +enum class PqContentType { + SINGLE_PANE, DUAL_PANE +} diff --git a/core/designsystem/src/main/res/values-zh-rTW/strings.xml b/core/designsystem/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..b1ff95d --- /dev/null +++ b/core/designsystem/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,5 @@ + + + CLOSE + 功能尚未上線 🙈 + \ No newline at end of file diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml new file mode 100644 index 0000000..be1ea8f --- /dev/null +++ b/core/designsystem/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + CLOSE + Functionality not available 🙈 + \ No newline at end of file diff --git a/feature/home/.gitignore b/feature/home/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/home/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts new file mode 100644 index 0000000..335b939 --- /dev/null +++ b/feature/home/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.pq.android.feature) + alias(libs.plugins.pq.android.library.compose) + alias(libs.plugins.pq.android.hilt) +} + +android { + namespace = "com.wei.picquest.feature.home" +} + +dependencies { +} \ No newline at end of file diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/home/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt b/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt new file mode 100644 index 0000000..6e49664 --- /dev/null +++ b/feature/home/src/main/java/com/wei/picquest/feature/home/home/HomeScreen.kt @@ -0,0 +1,115 @@ +package com.wei.picquest.feature.home.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.navigation.NavController +import com.wei.picquest.core.designsystem.component.FunctionalityNotAvailablePopup +import com.wei.picquest.core.designsystem.component.ThemePreviews +import com.wei.picquest.core.designsystem.theme.PqTheme + +/** + * + * UI 事件決策樹 + * 下圖顯示了一個決策樹,用於查找處理特定事件用例的最佳方法。 + * + * ┌───────┐ + * │ Start │ + * └───┬───┘ + * ↓ + * ┌───────────────────────────────────┐ + * │ Where is event originated? │ + * └──────┬─────────────────────┬──────┘ + * ↓ ↓ + * UI ViewModel + * │ │ + * ┌─────────────────────────┐ ┌───────────────┐ + * │ When the event requires │ │ Update the UI │ + * │ ... │ │ State │ + * └─┬─────────────────────┬─┘ └───────────────┘ + * ↓ ↓ + * Business logic UI behavior logic + * │ │ + * ┌─────────────────────────────────┐ ┌──────────────────────────────────────┐ + * │ Delegate the business logic to │ │ Modify the UI element state in the │ + * │ the ViewModel │ │ UI directly │ + * └─────────────────────────────────┘ └──────────────────────────────────────┘ + * + * + */ +@Composable +internal fun HomeRoute( + navController: NavController, +) { + HomeScreen() +} + +@Composable +internal fun HomeScreen( + withTopSpacer: Boolean = true, + withBottomSpacer: Boolean = true, + isPreview: Boolean = false, +) { + val showPopup = remember { mutableStateOf(false) } + + if (showPopup.value) { + FunctionalityNotAvailablePopup( + onDismiss = { + showPopup.value = false + }, + ) + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (withTopSpacer) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + } + + Column { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "Screen not available \uD83D\uDE48", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .semantics { contentDescription = "" }, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + if (withBottomSpacer) { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } +} + +@ThemePreviews +@Composable +fun HomeScreenPreview() { + PqTheme { + HomeScreen( + isPreview = true, + ) + } +} diff --git a/feature/home/src/main/java/com/wei/picquest/feature/home/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/wei/picquest/feature/home/home/navigation/HomeNavigation.kt new file mode 100644 index 0000000..2014823 --- /dev/null +++ b/feature/home/src/main/java/com/wei/picquest/feature/home/home/navigation/HomeNavigation.kt @@ -0,0 +1,23 @@ +package com.wei.picquest.feature.home.home.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.wei.picquest.feature.home.home.HomeRoute + +const val homeRoute = "home_route" + +fun NavController.navigateToHome(navOptions: NavOptions? = null) { + this.navigate(homeRoute, navOptions) +} + +fun NavGraphBuilder.homeGraph( + navController: NavController, +) { + composable(route = homeRoute) { + HomeRoute( + navController = navController, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cfd3d6a..baf125c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,3 +27,5 @@ include(":core:model") include(":core:network") include(":ui-test-hilt-manifest") + +include(":feature:home")