diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6ec973..0eaf225 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { implementation(projects.feature.foxFeatureApi) implementation(projects.feature.foxFeatureImpl) implementation(projects.core) + implementation(projects.data) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) diff --git a/app/src/main/java/com/featuremodule/template/MainActivity.kt b/app/src/main/java/com/featuremodule/template/MainActivity.kt index 7f523fe..e717629 100644 --- a/app/src/main/java/com/featuremodule/template/MainActivity.kt +++ b/app/src/main/java/com/featuremodule/template/MainActivity.kt @@ -10,10 +10,15 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.featuremodule.core.ui.theme.AppTheme import com.featuremodule.template.ui.AppContent +import com.featuremodule.template.ui.ThemeState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableStateFlow @@ -29,8 +34,16 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge(statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)) setContent { - AppTheme { - AppContent(updateLoadedState = { isLoaded.value = it }) + var theme by remember { mutableStateOf(ThemeState()) } + AppTheme( + colorsLight = theme.colorsLight, + colorsDark = theme.colorsDark, + themeStyle = theme.themeStyle, + ) { + AppContent( + updateLoadedState = { isLoaded.value = it }, + updateTheme = { theme = it }, + ) } } } diff --git a/app/src/main/java/com/featuremodule/template/ui/AppContent.kt b/app/src/main/java/com/featuremodule/template/ui/AppContent.kt index aae5433..b593aec 100644 --- a/app/src/main/java/com/featuremodule/template/ui/AppContent.kt +++ b/app/src/main/java/com/featuremodule/template/ui/AppContent.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,8 +20,9 @@ import com.featuremodule.core.util.CollectWithLifecycle @Composable internal fun AppContent( - viewModel: MainVM = hiltViewModel(), updateLoadedState: (isLoaded: Boolean) -> Unit, + updateTheme: (ThemeState) -> Unit, + viewModel: MainVM = hiltViewModel(), ) { val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() @@ -33,6 +33,10 @@ internal fun AppContent( updateLoadedState(state.isLoaded) } + LaunchedEffect(state.theme, updateTheme) { + updateTheme(state.theme) + } + state.commands.CollectWithLifecycle { navController.handleCommand(it) } @@ -46,9 +50,8 @@ internal fun AppContent( currentDestination = backStackEntry?.destination, ) }, - contentWindowInsets = WindowInsets(0), - // Remove this and status bar coloring in AppTheme for edge to edge - modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), + // Remove this for edge to edge + contentWindowInsets = WindowInsets.statusBars, ) { innerPadding -> AppNavHost( navController = navController, diff --git a/app/src/main/java/com/featuremodule/template/ui/MainContract.kt b/app/src/main/java/com/featuremodule/template/ui/MainContract.kt index 6c81a5b..769ae3a 100644 --- a/app/src/main/java/com/featuremodule/template/ui/MainContract.kt +++ b/app/src/main/java/com/featuremodule/template/ui/MainContract.kt @@ -1,15 +1,26 @@ package com.featuremodule.template.ui +import androidx.compose.material3.ColorScheme import com.featuremodule.core.navigation.NavCommand import com.featuremodule.core.ui.UiEvent import com.featuremodule.core.ui.UiState +import com.featuremodule.core.ui.theme.ColorsDark +import com.featuremodule.core.ui.theme.ColorsLight +import com.featuremodule.core.ui.theme.ThemeStyle import kotlinx.coroutines.flow.SharedFlow internal data class State( val commands: SharedFlow, val isLoaded: Boolean = false, + val theme: ThemeState = ThemeState(), ) : UiState +internal data class ThemeState( + val colorsLight: ColorScheme = ColorsLight.Default.scheme, + val colorsDark: ColorScheme = ColorsDark.Default.scheme, + val themeStyle: ThemeStyle = ThemeStyle.System, +) + internal sealed interface Event : UiEvent { data class OpenNavBarRoute(val route: String, val isSelected: Boolean) : Event } diff --git a/app/src/main/java/com/featuremodule/template/ui/MainVM.kt b/app/src/main/java/com/featuremodule/template/ui/MainVM.kt index 2a6cca4..fcc0935 100644 --- a/app/src/main/java/com/featuremodule/template/ui/MainVM.kt +++ b/app/src/main/java/com/featuremodule/template/ui/MainVM.kt @@ -3,20 +3,35 @@ package com.featuremodule.template.ui import com.featuremodule.core.navigation.NavCommand import com.featuremodule.core.navigation.NavManager import com.featuremodule.core.ui.BaseVM +import com.featuremodule.core.ui.theme.ColorsDark +import com.featuremodule.core.ui.theme.ColorsLight +import com.featuremodule.core.ui.theme.ThemeStyle +import com.featuremodule.data.prefs.ThemePreferences import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel internal class MainVM @Inject constructor( private val navManager: NavManager, + private val themePreferences: ThemePreferences, ) : BaseVM() { init { launch { - // Do something useful before loading - setState { copy(isLoaded = true) } + // The only loading for now is theme loading, so isLoaded is set together + themePreferences.themeModelFlow.collect { + setState { copy(theme = it.toThemeState(), isLoaded = true) } + } } } + private fun ThemePreferences.ThemeModel.toThemeState() = ThemeState( + colorsLight = ColorsLight.entries.find { it.name == lightTheme }?.scheme + ?: ColorsLight.Default.scheme, + colorsDark = ColorsDark.entries.find { it.name == darkTheme }?.scheme + ?: ColorsDark.Default.scheme, + themeStyle = ThemeStyle.entries.find { it.name == themeStyle } ?: ThemeStyle.System, + ) + override fun initialState() = State(navManager.commands) override fun handleEvent(event: Event) { diff --git a/core/src/main/java/com/featuremodule/core/ui/theme/AppTheme.kt b/core/src/main/java/com/featuremodule/core/ui/theme/AppTheme.kt index 8b42357..b1d7ad4 100644 --- a/core/src/main/java/com/featuremodule/core/ui/theme/AppTheme.kt +++ b/core/src/main/java/com/featuremodule/core/ui/theme/AppTheme.kt @@ -1,33 +1,41 @@ package com.featuremodule.core.ui.theme +import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40, -) +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat @Composable -fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colorScheme = if (darkTheme) { - DarkColorScheme - } else { - LightColorScheme +fun AppTheme( + colorsLight: ColorScheme, + colorsDark: ColorScheme, + themeStyle: ThemeStyle, + content: @Composable () -> Unit, +) { + val isStyleDark = when (themeStyle) { + ThemeStyle.Light -> false + ThemeStyle.Dark -> true + ThemeStyle.System -> isSystemInDarkTheme() + } + val colorScheme = if (isStyleDark) colorsDark else colorsLight + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowCompat.getInsetsController(window, view) + .isAppearanceLightStatusBars = !isStyleDark + WindowCompat.getInsetsController(window, view) + .isAppearanceLightNavigationBars = !isStyleDark + } } - ProvideAppColors(darkTheme) { + ProvideAppColors(isStyleDark) { MaterialTheme( colorScheme = colorScheme, typography = Typography, diff --git a/core/src/main/java/com/featuremodule/core/ui/theme/Color.kt b/core/src/main/java/com/featuremodule/core/ui/theme/Color.kt deleted file mode 100644 index cc8af07..0000000 --- a/core/src/main/java/com/featuremodule/core/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.featuremodule.core.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) diff --git a/core/src/main/java/com/featuremodule/core/ui/theme/LocalAppColors.kt b/core/src/main/java/com/featuremodule/core/ui/theme/LocalAppColors.kt index 7d23412..e428be1 100644 --- a/core/src/main/java/com/featuremodule/core/ui/theme/LocalAppColors.kt +++ b/core/src/main/java/com/featuremodule/core/ui/theme/LocalAppColors.kt @@ -13,17 +13,17 @@ val LocalAppColors = staticCompositionLocalOf { AppColors() } * Draft for providing own color hierarchy to be used in the same way as MaterialTheme. */ data class AppColors( - val primary: Color = Purple40, - val secondary: Color = PurpleGrey40, - val tertiary: Color = Pink40, + val primary: Color = ColorsLight.Default.scheme.primary, + val secondary: Color = ColorsLight.Default.scheme.secondary, + val tertiary: Color = ColorsLight.Default.scheme.tertiary, ) private val LightAppColors = AppColors() private val DarkAppColors = AppColors( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, + primary = ColorsDark.Default.scheme.primary, + secondary = ColorsDark.Default.scheme.secondary, + tertiary = ColorsDark.Default.scheme.tertiary, ) @Composable diff --git a/core/src/main/java/com/featuremodule/core/ui/theme/Themes.kt b/core/src/main/java/com/featuremodule/core/ui/theme/Themes.kt new file mode 100644 index 0000000..a84b1c4 --- /dev/null +++ b/core/src/main/java/com/featuremodule/core/ui/theme/Themes.kt @@ -0,0 +1,191 @@ +@file:Suppress("MagicNumber") + +package com.featuremodule.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +enum class ColorsLight(val scheme: ColorScheme) { + Default( + lightColorScheme( + primary = Color(0xFF6650A4), + secondary = Color(0xFF625B71), + tertiary = Color(0xFF7D5260), + ), + ), + Red( + lightColorScheme( + primary = Color(0xFFA50011), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFEA001D), + onPrimaryContainer = Color(0xFFFFFFFF), + secondary = Color(0xFFB32826), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFFF776C), + onSecondaryContainer = Color(0xFF350002), + tertiary = Color(0xFF774300), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFAB6300), + onTertiaryContainer = Color(0xFFFFFFFF), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + background = Color(0xFFFFF8F7), + onBackground = Color(0xFF2A1614), + surface = Color(0xFFFFF8F7), + onSurface = Color(0xFF2A1614), + surfaceVariant = Color(0xFFFFDAD6), + onSurfaceVariant = Color(0xFF5F3F3B), + outline = Color(0xFF946E6A), + outlineVariant = Color(0xFFE9BCB7), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFF412B28), + inverseOnSurface = Color(0xFFFFEDEA), + inversePrimary = Color(0xFFFFB4AC), + surfaceDim = Color(0xFFF6D2CE), + surfaceBright = Color(0xFFFFF8F7), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFFFF0EF), + surfaceContainer = Color(0xFFFFE9E6), + surfaceContainerHigh = Color(0xFFFFE2DE), + surfaceContainerHighest = Color(0xFFFFDAD6), + ), + ), + Green( + lightColorScheme( + primary = Color(0xFF3A693B), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFBBF0B6), + onPrimaryContainer = Color(0xFF002105), + secondary = Color(0xFF52634F), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFD5E8CF), + onSecondaryContainer = Color(0xFF101F10), + tertiary = Color(0xFF39656B), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCEBF1), + onTertiaryContainer = Color(0xFF001F23), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + background = Color(0xFFF7FBF1), + onBackground = Color(0xFF181D17), + surface = Color(0xFFF7FBF1), + onSurface = Color(0xFF181D17), + surfaceVariant = Color(0xFFDEE5D9), + onSurfaceVariant = Color(0xFF424940), + outline = Color(0xFF72796F), + outlineVariant = Color(0xFFC2C9BD), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFF2D322C), + inverseOnSurface = Color(0xFFEEF2E9), + inversePrimary = Color(0xFFA0D49B), + surfaceDim = Color(0xFFD7DBD2), + surfaceBright = Color(0xFFF7FBF1), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFF1F5EC), + surfaceContainer = Color(0xFFEBEFE6), + surfaceContainerHigh = Color(0xFFE6E9E0), + surfaceContainerHighest = Color(0xFFE0E4DB), + ), + ), +} + +enum class ColorsDark(val scheme: ColorScheme) { + Default( + darkColorScheme( + primary = Color(0xFFD0BCFF), + secondary = Color(0xFFCCC2DC), + tertiary = Color(0xFFEFB8C8), + ), + ), + Red( + darkColorScheme( + primary = Color(0xFFFFB4AC), + onPrimary = Color(0xFF690007), + primaryContainer = Color(0xFFDF001B), + onPrimaryContainer = Color(0xFFFFFFFF), + secondary = Color(0xFFFFB4AC), + onSecondary = Color(0xFF690007), + secondaryContainer = Color(0xFF86000C), + onSecondaryContainer = Color(0xFFFFC8C2), + tertiary = Color(0xFFFFB873), + onTertiary = Color(0xFF4B2800), + tertiaryContainer = Color(0xFFA35E00), + onTertiaryContainer = Color(0xFFFFFFFF), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF210E0D), + onBackground = Color(0xFFFFDAD6), + surface = Color(0xFF210E0D), + onSurface = Color(0xFFFFDAD6), + surfaceVariant = Color(0xFF5F3F3B), + onSurfaceVariant = Color(0xFFE9BCB7), + outline = Color(0xFFB08783), + outlineVariant = Color(0xFF5F3F3B), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFFFFDAD6), + inverseOnSurface = Color(0xFF412B28), + inversePrimary = Color(0xFFC00016), + surfaceDim = Color(0xFF210E0D), + surfaceBright = Color(0xFF4B3331), + surfaceContainerLowest = Color(0xFF1B0908), + surfaceContainerLow = Color(0xFF2A1614), + surfaceContainer = Color(0xFF2E1A18), + surfaceContainerHigh = Color(0xFF3A2422), + surfaceContainerHighest = Color(0xFF462F2D), + ), + ), + Green( + darkColorScheme( + primary = Color(0xFFA0D49B), + onPrimary = Color(0xFF073910), + primaryContainer = Color(0xFF225025), + onPrimaryContainer = Color(0xFFBBF0B6), + secondary = Color(0xFFB9CCB4), + onSecondary = Color(0xFF253423), + secondaryContainer = Color(0xFF3B4B39), + onSecondaryContainer = Color(0xFFD5E8CF), + tertiary = Color(0xFFA1CED5), + onTertiary = Color(0xFF00363C), + tertiaryContainer = Color(0xFF1F4D53), + onTertiaryContainer = Color(0xFFBCEBF1), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF10140F), + onBackground = Color(0xFFE0E4DB), + surface = Color(0xFF10140F), + onSurface = Color(0xFFE0E4DB), + surfaceVariant = Color(0xFF424940), + onSurfaceVariant = Color(0xFFC2C9BD), + outline = Color(0xFF8C9388), + outlineVariant = Color(0xFF424940), + scrim = Color(0xFF000000), + inverseSurface = Color(0xFFE0E4DB), + inverseOnSurface = Color(0xFF2D322C), + inversePrimary = Color(0xFF3A693B), + surfaceDim = Color(0xFF10140F), + surfaceBright = Color(0xFF363A34), + surfaceContainerLowest = Color(0xFF0B0F0A), + surfaceContainerLow = Color(0xFF181D17), + surfaceContainer = Color(0xFF1C211B), + surfaceContainerHigh = Color(0xFF272B25), + surfaceContainerHighest = Color(0xFF323630), + ), + ), +} + +/** Sets light or dark theme as active, or switches it with system */ +enum class ThemeStyle { + Light, + Dark, + System, +} diff --git a/data/src/main/java/com/featuremodule/data/prefs/ThemePreferences.kt b/data/src/main/java/com/featuremodule/data/prefs/ThemePreferences.kt new file mode 100644 index 0000000..a8c44b0 --- /dev/null +++ b/data/src/main/java/com/featuremodule/data/prefs/ThemePreferences.kt @@ -0,0 +1,60 @@ +package com.featuremodule.data.prefs + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.onStart +import javax.inject.Inject + +class ThemePreferences @Inject constructor( + @ApplicationContext context: Context, +) { + private val preferences = context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) + + fun setLightTheme(theme: String?) = preferences.edit { putString(KEY_THEME_LIGHT, theme) } + + fun setDarkTheme(theme: String?) = preferences.edit { putString(KEY_THEME_DARK, theme) } + + fun setThemeStyle(theme: String?) = preferences.edit { putString(KEY_THEME_STYLE, theme) } + + fun setAll(themeModel: ThemeModel) = preferences.edit { + putString(KEY_THEME_LIGHT, themeModel.lightTheme) + putString(KEY_THEME_DARK, themeModel.darkTheme) + putString(KEY_THEME_STYLE, themeModel.themeStyle) + } + + val themeModelFlow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> + // trySendBlocking is used just in case, trySend should be enough too + trySendBlocking(getCurrentPreferences()) + } + preferences.registerOnSharedPreferenceChangeListener(listener) + + awaitClose { preferences.unregisterOnSharedPreferenceChangeListener(listener) } + }.onStart { + emit(getCurrentPreferences()) + } + + fun getCurrentPreferences() = ThemeModel( + lightTheme = preferences.getString(KEY_THEME_LIGHT, null), + darkTheme = preferences.getString(KEY_THEME_DARK, null), + themeStyle = preferences.getString(KEY_THEME_STYLE, null), + ) + + data class ThemeModel( + val lightTheme: String?, + val darkTheme: String?, + val themeStyle: String?, + ) + + companion object { + private const val FILE_NAME = "theme_preferences" + private const val KEY_THEME_LIGHT = "theme_light" + private const val KEY_THEME_DARK = "theme_dark" + private const val KEY_THEME_STYLE = "theme_style" + } +} diff --git a/feature/homeImpl/build.gradle.kts b/feature/homeImpl/build.gradle.kts index 55eb848..06219e8 100644 --- a/feature/homeImpl/build.gradle.kts +++ b/feature/homeImpl/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(projects.feature.homeApi) implementation(projects.feature.featureAApi) + implementation(projects.data) implementation(libs.bundles.exoplayer) implementation(libs.bundles.camerax) diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt index beaf434..9cbbe08 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt @@ -14,6 +14,7 @@ import com.featuremodule.homeImpl.barcode.BarcodeResultScreen import com.featuremodule.homeImpl.camera.TakePhotoScreen import com.featuremodule.homeImpl.exoplayer.ExoplayerScreen import com.featuremodule.homeImpl.imageUpload.ImageUploadScreen +import com.featuremodule.homeImpl.theming.ChooseThemeScreen import com.featuremodule.homeImpl.ui.HomeScreen import com.featuremodule.homeImpl.wifi.WifiScreen @@ -54,6 +55,10 @@ fun NavGraphBuilder.registerHome() { composable(InternalRoutes.WifiDestination.ROUTE) { WifiScreen() } + + composable(InternalRoutes.ChooseThemeDestination.ROUTE) { + ChooseThemeScreen() + } } internal class InternalRoutes { @@ -98,4 +103,10 @@ internal class InternalRoutes { fun constructRoute() = ROUTE } + + object ChooseThemeDestination { + const val ROUTE = "choose_theme" + + fun constructRoute() = ROUTE + } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeContract.kt new file mode 100644 index 0000000..18075ee --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeContract.kt @@ -0,0 +1,30 @@ +package com.featuremodule.homeImpl.theming + +import com.featuremodule.core.ui.UiEvent +import com.featuremodule.core.ui.UiState +import com.featuremodule.core.ui.theme.ColorsDark +import com.featuremodule.core.ui.theme.ColorsLight +import com.featuremodule.core.ui.theme.ThemeStyle + +internal data class State( + val previewTheme: ThemeState = ThemeState(), + val isThemeSaved: Boolean = true, + val showSaveCloseDialog: Boolean = false, +) : UiState + +internal data class ThemeState( + val colorsLight: ColorsLight = ColorsLight.Default, + val colorsDark: ColorsDark = ColorsDark.Default, + val themeStyle: ThemeStyle = ThemeStyle.System, +) + +internal sealed interface Event : UiEvent { + data class SetLightTheme(val colors: ColorsLight) : Event + data class SetDarkTheme(val colors: ColorsDark) : Event + data class SetThemeStyle(val themeStyle: ThemeStyle) : Event + data object SaveTheme : Event + + data object PopBackIfSaved : Event + data object PopBack : Event + data object HideSaveCloseDialog : Event +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeScreen.kt new file mode 100644 index 0000000..3786b7d --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeScreen.kt @@ -0,0 +1,319 @@ +package com.featuremodule.homeImpl.theming + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.featuremodule.core.ui.theme.ColorsDark +import com.featuremodule.core.ui.theme.ColorsLight +import com.featuremodule.core.ui.theme.ThemeStyle +import com.featuremodule.homeImpl.R + +@Composable +internal fun ChooseThemeScreen(viewModel: ChooseThemeVM = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + floatingActionButton = { + if (!state.isThemeSaved) { + FloatingActionButton(onClick = { viewModel.postEvent(Event.SaveTheme) }) { + Icon( + painter = painterResource(R.drawable.save), + contentDescription = null, + ) + } + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + ThemeStyleChooser(state.previewTheme.themeStyle, viewModel::postEvent) + + ThemeChooserBlock("Light themes", state.previewTheme.colorsLight.scheme) { + items(ColorsLight.entries) { + ThemeRadioButton( + name = it.name, + colorScheme = it.scheme, + isSelected = state.previewTheme.colorsLight == it, + onClick = { viewModel.postEvent(Event.SetLightTheme(it)) }, + ) + } + } + + ThemeChooserBlock("Dark themes", state.previewTheme.colorsDark.scheme) { + items(ColorsDark.entries) { + ThemeRadioButton( + name = it.name, + colorScheme = it.scheme, + isSelected = state.previewTheme.colorsDark == it, + onClick = { viewModel.postEvent(Event.SetDarkTheme(it)) }, + ) + } + } + } + } + + if (state.showSaveCloseDialog) { + SaveOrCloseDialog( + onDismiss = { viewModel.postEvent(Event.HideSaveCloseDialog) }, + onSaveClose = { + viewModel.postEvent(Event.SaveTheme) + viewModel.postEvent(Event.PopBack) + }, + onNoSaveClose = { viewModel.postEvent(Event.PopBack) }, + onNoClose = { viewModel.postEvent(Event.HideSaveCloseDialog) }, + ) + } + + BackHandler(enabled = !state.isThemeSaved) { + viewModel.postEvent(Event.PopBackIfSaved) + } +} + +@Composable +private fun ThemeStyleChooser(themeStyle: ThemeStyle, postEvent: (Event) -> Unit) { + var isThemeDropdownExpanded by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.height(48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Theme style", + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f), + ) + + Box { + // Additional clip is needed to limit Ripple effect + Text( + text = themeStyle.toString(), + modifier = Modifier + .padding(horizontal = 8.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = MaterialTheme.shapes.medium, + ) + .clip(shape = MaterialTheme.shapes.medium) + .clickable { isThemeDropdownExpanded = !isThemeDropdownExpanded } + .padding(all = 8.dp), + ) + + DropdownMenu( + expanded = isThemeDropdownExpanded, + onDismissRequest = { isThemeDropdownExpanded = false }, + ) { + @Composable + fun Item(text: String, style: ThemeStyle) = DropdownMenuItem( + text = { Text(text) }, + onClick = { + postEvent(Event.SetThemeStyle(style)) + isThemeDropdownExpanded = false + }, + ) + + Item("Light", ThemeStyle.Light) + Item("Dark", ThemeStyle.Dark) + Item("System", ThemeStyle.System) + } + } + } +} + +@Composable +private fun ThemeChooserBlock( + title: String, + colorScheme: ColorScheme, + radioButtonsContent: LazyListScope.() -> Unit, +) = Column { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 8.dp), + ) + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(all = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + radioButtonsContent() + } + ThemePreview(colorScheme) + Spacer(Modifier.height(24.dp)) +} + +@Composable +private fun ThemeRadioButton( + name: String, + colorScheme: ColorScheme, + isSelected: Boolean, + onClick: () -> Unit, +) = Card(modifier = Modifier.clickable { onClick() }) { + Column( + modifier = Modifier.padding(all = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(name) + Row(modifier = Modifier.size(height = 20.dp, width = 40.dp)) { + Box( + modifier = Modifier + .background( + color = colorScheme.primary, + shape = RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp), + ) + .fillMaxHeight() + .weight(1f), + ) + Box( + modifier = Modifier + .background(color = colorScheme.secondary) + .fillMaxHeight() + .weight(1f), + ) + Box( + modifier = Modifier + .background( + color = colorScheme.tertiary, + shape = RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp), + ) + .fillMaxHeight() + .weight(1f), + ) + } + RadioButton( + selected = isSelected, + onClick = null, + ) + } +} + +@Composable +private fun ThemePreview(colorScheme: ColorScheme) { + MaterialTheme(colorScheme = colorScheme) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.large, + ) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.large, + ) + .padding(all = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(text = "Theme Preview", color = MaterialTheme.colorScheme.onBackground) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = {}) { Text("Button") } + OutlinedButton(onClick = {}) { Text("Button") } + TextButton(onClick = {}) { Text("Button") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Card(onClick = {}) { + Text(text = "Card", modifier = Modifier.padding(12.dp)) + } + OutlinedCard(onClick = {}) { + Text(text = "Card", modifier = Modifier.padding(12.dp)) + } + } + } + } +} + +@Composable +private fun SaveOrCloseDialog( + onDismiss: () -> Unit, + onSaveClose: () -> Unit, + onNoSaveClose: () -> Unit, + onNoClose: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(all = 24.dp)) { + Text( + text = "Changes aren't saved", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onSaveClose, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save and close") + } + OutlinedButton( + onClick = onNoSaveClose, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Don't save and close") + } + OutlinedButton( + onClick = onNoClose, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Don't close") + } + } + } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeVM.kt new file mode 100644 index 0000000..f8a517e --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/theming/ChooseThemeVM.kt @@ -0,0 +1,79 @@ +package com.featuremodule.homeImpl.theming + +import com.featuremodule.core.navigation.NavCommand +import com.featuremodule.core.navigation.NavManager +import com.featuremodule.core.ui.BaseVM +import com.featuremodule.core.ui.theme.ColorsDark +import com.featuremodule.core.ui.theme.ColorsLight +import com.featuremodule.core.ui.theme.ThemeStyle +import com.featuremodule.data.prefs.ThemePreferences +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class ChooseThemeVM @Inject constructor( + private val themePreferences: ThemePreferences, + private val navManager: NavManager, +) : BaseVM() { + private lateinit var savedTheme: ThemeState + + init { + launch { + loadSavedTheme() + setState { copy(previewTheme = savedTheme) } + } + } + + private fun loadSavedTheme() { + savedTheme = themePreferences.getCurrentPreferences().toThemeState() + } + + private fun ThemePreferences.ThemeModel.toThemeState() = ThemeState( + colorsLight = ColorsLight.entries.find { it.name == lightTheme } + ?: ColorsLight.Default, + colorsDark = ColorsDark.entries.find { it.name == darkTheme } + ?: ColorsDark.Default, + themeStyle = ThemeStyle.entries.find { it.name == themeStyle } ?: ThemeStyle.System, + ) + + override fun initialState() = State() + + override fun handleEvent(event: Event) { + when (event) { + is Event.SetLightTheme -> updatePreviewTheme { copy(colorsLight = event.colors) } + is Event.SetDarkTheme -> updatePreviewTheme { copy(colorsDark = event.colors) } + is Event.SetThemeStyle -> updatePreviewTheme { copy(themeStyle = event.themeStyle) } + Event.SaveTheme -> saveTheme() + Event.PopBackIfSaved -> { + if (state.value.previewTheme == savedTheme) { + launch { navManager.navigate(NavCommand.PopBack) } + return + } + setState { copy(showSaveCloseDialog = true) } + } + + Event.PopBack -> launch { navManager.navigate(NavCommand.PopBack) } + Event.HideSaveCloseDialog -> setState { copy(showSaveCloseDialog = false) } + } + } + + private fun updatePreviewTheme(newTheme: ThemeState.() -> ThemeState) = setState { + val updatedTheme = previewTheme.newTheme() + copy( + previewTheme = updatedTheme, + isThemeSaved = updatedTheme == savedTheme, + ) + } + + private fun saveTheme() = with(state.value.previewTheme) { + themePreferences.setAll( + ThemePreferences.ThemeModel( + lightTheme = colorsLight.name, + darkTheme = colorsDark.name, + themeStyle = themeStyle.name, + ), + ) + loadSavedTheme() + setState { copy(isThemeSaved = true) } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt index 7a385b7..5d2d1cc 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt @@ -11,4 +11,5 @@ internal sealed interface Event : UiEvent { data object NavigateToCamera : Event data object NavigateToBarcode : Event data object NavigateToWifi : Event + data object NavigateToChooseTheme : Event } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt index 77b31cf..47c44e4 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt @@ -48,6 +48,7 @@ internal fun HomeScreen(route: String?, viewModel: HomeVM = hiltViewModel()) { GenericButton(text = "Camera") { viewModel.postEvent(Event.NavigateToCamera) } GenericButton(text = "Barcode") { viewModel.postEvent(Event.NavigateToBarcode) } GenericButton(text = "Wifi") { viewModel.postEvent(Event.NavigateToWifi) } + GenericButton(text = "Theme") { viewModel.postEvent(Event.NavigateToChooseTheme) } } } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt index 3e6699e..4c54958 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt @@ -44,6 +44,10 @@ internal class HomeVM @Inject constructor( Event.NavigateToWifi -> navManager.navigate( NavCommand.Forward(InternalRoutes.WifiDestination.constructRoute()), ) + + Event.NavigateToChooseTheme -> navManager.navigate( + NavCommand.Forward(InternalRoutes.ChooseThemeDestination.constructRoute()), + ) } } } diff --git a/feature/homeImpl/src/main/res/drawable/save.xml b/feature/homeImpl/src/main/res/drawable/save.xml new file mode 100644 index 0000000..5c5be9a --- /dev/null +++ b/feature/homeImpl/src/main/res/drawable/save.xml @@ -0,0 +1,11 @@ + + + + +