diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt index 50b8c11..ca71d59 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt @@ -1,5 +1,6 @@ package io.github.smiling_pixel +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -9,7 +10,6 @@ import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.CenterAlignedTopAppBar @@ -18,6 +18,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,8 +38,9 @@ import io.github.smiling_pixel.filesystem.FileRepository import io.github.smiling_pixel.filesystem.InMemoryFileManager import io.github.smiling_pixel.database.InMemoryFileMetadataDao import io.github.smiling_pixel.client.GoogleWeatherClient -import io.github.smiling_pixel.client.WeatherClient +import io.github.smiling_pixel.theme.ThemeMode import io.github.smiling_pixel.preference.getSettingsRepository +import io.github.smiling_pixel.theme.MarkDayTheme import io.github.smiling_pixel.screens.EntriesScreen import io.github.smiling_pixel.screens.EntryDetailsScreen import io.github.smiling_pixel.screens.InsightsScreen @@ -71,16 +73,33 @@ fun App( providedRepo: io.github.smiling_pixel.database.DiaryRepository? = null, providedFileRepo: FileRepository? = null ) { - setSingletonImageLoaderFactory { context -> - getAsyncImageLoader(context) + // Memoize the image loader factory to prevent it from being re-created on every recomposition. + // While setSingletonImageLoaderFactory is a @Composable, we use remember here to ensure the + // factory lambda remains stable across theme changes and other UI updates. + val imageLoaderFactory = remember { + { context: coil3.PlatformContext -> getAsyncImageLoader(context) } } + setSingletonImageLoaderFactory(imageLoaderFactory) - MaterialTheme { + val settingsRepository = remember { getSettingsRepository() } + val themeMode by settingsRepository.themeMode.collectAsState(initial = null) + val isPureBlackEnabled by settingsRepository.isPureBlackEnabled.collectAsState(initial = null) + + if (themeMode == null || isPureBlackEnabled == null) { + return + } + + val useDarkTheme = themeMode == ThemeMode.DARK || (themeMode == ThemeMode.SYSTEM && isSystemInDarkTheme()) + + MarkDayTheme( + useDarkTheme = useDarkTheme, + isPureBlack = isPureBlackEnabled!! + ) { val repo = providedRepo ?: remember { DiaryRepository(InMemoryDiaryDao()) } val fileRepo = providedFileRepo ?: remember { FileRepository(InMemoryFileManager(), InMemoryFileMetadataDao()) } - val weatherClient = remember { GoogleWeatherClient(getSettingsRepository()) } + val weatherClient = remember { GoogleWeatherClient(settingsRepository) } val scope = rememberCoroutineScope() val navController = rememberNavController() var selected by remember { mutableStateOf(EntriesRoute) } diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.kt index 43aa8a5..b19820e 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.kt @@ -1,8 +1,24 @@ package io.github.smiling_pixel.preference +import io.github.smiling_pixel.theme.ThemeMode import kotlinx.coroutines.flow.Flow interface SettingsRepository { + /** + * A flow emitting the current [ThemeMode] setting of the application. + * Default is [ThemeMode.SYSTEM]. + */ + val themeMode: Flow + + /** + * Updates the application's theme mode setting. + * @param mode The new [ThemeMode] to be applied. + */ + suspend fun setThemeMode(mode: ThemeMode) + + val isPureBlackEnabled: Flow + suspend fun setPureBlackEnabled(enabled: Boolean) + val googleWeatherApiKey: Flow suspend fun setGoogleWeatherApiKey(key: String?) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt index e21361f..e48b1a3 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntryDetailsScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlin.time.ExperimentalTime @@ -55,6 +54,16 @@ import io.github.smiling_pixel.util.Logger import io.github.smiling_pixel.util.e import io.github.smiling_pixel.util.w +/** + * Screen displaying the details of a diary entry. + * It allows the user to view the entry's content, title, date, and weather, + * or edit these details if they switch to edit mode. + * + * @param entry The [DiaryEntry] to display or edit. If null, creates a new entry. + * @param weatherClient The [WeatherClient] to fetch weather information. + * @param onSave Callback invoked when the user saves the entry. + * @param onCancel Callback invoked when the user cancels editing or goes back. + */ @OptIn(ExperimentalTime::class, ExperimentalMaterial3Api::class) @Composable fun EntryDetailsScreen( @@ -201,7 +210,7 @@ fun EntryDetailsScreen( } Spacer(modifier = Modifier.height(12.dp)) - HorizontalDivider(thickness = 1.dp, color = Color.Gray) + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(12.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -296,7 +305,7 @@ fun EntryDetailsScreen( Text( text = "Created: ${createdLocal.date} $createdTimeStr", style = MaterialTheme.typography.bodySmall, - color = Color.Gray + color = MaterialTheme.colorScheme.outlineVariant ) Spacer(modifier = Modifier.height(6.dp)) @@ -305,7 +314,7 @@ fun EntryDetailsScreen( Text( text = "Updated: ${updatedLocal.date} $updatedTimeStr", style = MaterialTheme.typography.bodySmall, - color = Color.Gray + color = MaterialTheme.colorScheme.outlineVariant ) Spacer(modifier = Modifier.height(12.dp)) @@ -318,7 +327,7 @@ fun EntryDetailsScreen( Spacer(modifier = Modifier.height(6.dp)) } - HorizontalDivider(thickness = 1.dp, color = Color.Gray) + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(12.dp)) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/MomentsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/MomentsScreen.kt index 4f3fe8d..1251893 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/MomentsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/MomentsScreen.kt @@ -36,7 +36,6 @@ 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.graphics.Color import androidx.compose.ui.unit.dp import io.github.smiling_pixel.filesystem.FileRepository import io.github.smiling_pixel.model.FileMetadata diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SettingsScreen.kt index 6931a48..84e1122 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SettingsScreen.kt @@ -13,11 +13,25 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import androidx.compose.ui.Alignment +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.layout.Row import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Switch import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -37,7 +51,11 @@ import kotlinx.coroutines.launch @Composable fun SettingsScreen() { val scope = rememberCoroutineScope() - val settingsRepository = getSettingsRepository() + // Remember the settings repository so recomposition does not recreate a new DataStore-backed + // repository instance and resubscribe all mapped flows unnecessarily. + val settingsRepository = remember { getSettingsRepository() } + val themeMode by settingsRepository.themeMode.collectAsState(initial = io.github.smiling_pixel.theme.ThemeMode.SYSTEM) + val isPureBlackEnabled by settingsRepository.isPureBlackEnabled.collectAsState(initial = false) val apiKey by settingsRepository.googleWeatherApiKey.collectAsState(initial = null) val uriHandler = LocalUriHandler.current @@ -86,19 +104,100 @@ fun SettingsScreen() { Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp) ) { Text( text = "Settings", - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(32.dp)) + + /* + * A section to allow the user to select their preferred Theme Mode. + * The user can select from SYSTEM, LIGHT, or DARK modes using a group of rounded rectangles. + */ + Text( + text = "Appearance", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth().selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + io.github.smiling_pixel.theme.ThemeMode.entries.forEach { mode -> + val isSelected = themeMode == mode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = { + scope.launch { + settingsRepository.setThemeMode(mode) + } + } + ) + .background(if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant) + .then( + if (isSelected) Modifier.border(1.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + else Modifier + ) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = mode.name.lowercase().replaceFirstChar { it.uppercase() }, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + Text( + text = "Pure Black Mode", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Use pure black backgrounds in dark mode instead of dark gray, optimizing for OLED screens.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = isPureBlackEnabled, + onCheckedChange = { isChecked -> + scope.launch { + settingsRepository.setPureBlackEnabled(isChecked) + } + } + ) + } + + Spacer(modifier = Modifier.height(32.dp)) Text( text = "Third-party Services", - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( value = apiKey ?: "", onValueChange = { newKey -> @@ -111,13 +210,14 @@ fun SettingsScreen() { singleLine = true ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(32.dp)) Text( text = "Cloud Drive Sync", - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) errorMessage?.let { msg -> Text( @@ -244,20 +344,22 @@ fun SettingsScreen() { } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(32.dp)) Text( text = "About", - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) Text( text = "MarkDay Diary App v1.0.0", style = MaterialTheme.typography.bodyLarge ) Text( text = "A cross-platform diary application built with Kotlin Multiplatform and Compose.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/Color.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/Color.kt new file mode 100644 index 0000000..b15a468 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/Color.kt @@ -0,0 +1,103 @@ +package io.github.smiling_pixel.theme + +import androidx.compose.ui.graphics.Color + +// Light Theme Colors + +/** Primary color for the light theme, representing the main green brand. */ +val primaryLight = Color(0xFF386B36) +/** Color used for text/icons on top of the primary color in the light theme. */ +val onPrimaryLight = Color(0xFFFFFFFF) +/** A lighter tone of the primary color, used as a background for grouped elements in the light theme. */ +val primaryContainerLight = Color(0xFFB9F3B1) +/** Color used for text/icons on top of the primary container in the light theme. */ +val onPrimaryContainerLight = Color(0xFF002203) + +/** Secondary brand color for the light theme, used for less prominent accents. */ +val secondaryLight = Color(0xFF526350) +/** Color used for text/icons on top of the secondary color in the light theme. */ +val onSecondaryLight = Color(0xFFFFFFFF) +/** A lighter tone of the secondary color, used for secondary grouped elements in the light theme. */ +val secondaryContainerLight = Color(0xFFD5E8D0) +/** Color used for text/icons on top of the secondary container in the light theme. */ +val onSecondaryContainerLight = Color(0xFF111F0F) + +/** Tertiary brand color for the light theme, providing a contrasting accent (teal/blue). */ +val tertiaryLight = Color(0xFF39656B) +/** Color used for text/icons on top of the tertiary color in the light theme. */ +val onTertiaryLight = Color(0xFFFFFFFF) +/** A lighter tone of the tertiary color, used for tertiary grouped elements in the light theme. */ +val tertiaryContainerLight = Color(0xFFBCEBF2) +/** Color used for text/icons on top of the tertiary container in the light theme. */ +val onTertiaryContainerLight = Color(0xFF001F23) + +/** Error color for the light theme, indicating destructive actions or errors. */ +val errorLight = Color(0xFFBA1A1A) +/** Color used for text/icons on top of the error color in the light theme. */ +val onErrorLight = Color(0xFFFFFFFF) +/** A lighter tone of the error color, used as a subtle error background in the light theme. */ +val errorContainerLight = Color(0xFFFFDAD6) +/** Color used for text/icons on top of the error container in the light theme. */ +val onErrorContainerLight = Color(0xFF410002) + +/** Background color for the overall application layout in the light theme. */ +val backgroundLight = Color(0xFFF7FBF2) +/** Color used for text/icons on top of the overall background in the light theme. */ +val onBackgroundLight = Color(0xFF181D17) +/** Surface color for components like cards and menus in the light theme. */ +val surfaceLight = Color(0xFFF7FBF2) +/** Color used for text/icons on top of surfaces in the light theme. */ +val onSurfaceLight = Color(0xFF181D17) + + +// Dark Theme Colors + +/** Primary color for the dark theme, optimized for dark backgrounds. */ +val primaryDark = Color(0xFF9ED697) +/** Color used for text/icons on top of the primary color in the dark theme. */ +val onPrimaryDark = Color(0xFF05390C) +/** A darker tone of the primary color, used as a background for grouped elements in the dark theme. */ +val primaryContainerDark = Color(0xFF1F5120) +/** Color used for text/icons on top of the primary container in the dark theme. */ +val onPrimaryContainerDark = Color(0xFFB9F3B1) + +/** Secondary brand color for the dark theme. */ +val secondaryDark = Color(0xFFB9CCB5) +/** Color used for text/icons on top of the secondary color in the dark theme. */ +val onSecondaryDark = Color(0xFF253424) +/** A darker tone of the secondary color for the dark theme. */ +val secondaryContainerDark = Color(0xFF3B4B39) +/** Color used for text/icons on top of the secondary container in the dark theme. */ +val onSecondaryContainerDark = Color(0xFFD5E8D0) + +/** Tertiary brand color for the dark theme. */ +val tertiaryDark = Color(0xFFA0CFD5) +/** Color used for text/icons on top of the tertiary color in the dark theme. */ +val onTertiaryDark = Color(0xFF00363B) +/** A darker tone of the tertiary color for the dark theme. */ +val tertiaryContainerDark = Color(0xFF1E4D53) +/** Color used for text/icons on top of the tertiary container in the dark theme. */ +val onTertiaryContainerDark = Color(0xFFBCEBF2) + +/** Error color for the dark theme. */ +val errorDark = Color(0xFFFFB4AB) +/** Color used for text/icons on top of the error color in the dark theme. */ +val onErrorDark = Color(0xFF690005) +/** A darker tone of the error color for the dark theme. */ +val errorContainerDark = Color(0xFF93000A) +/** Color used for text/icons on top of the error container in the dark theme. */ +val onErrorContainerDark = Color(0xFFFFDAD6) + +/** Background color for the overall application layout in the dark theme. */ +val backgroundDark = Color(0xFF10140F) +/** Color used for text/icons on top of the overall background in the dark theme. */ +val onBackgroundDark = Color(0xFFE0E4DB) +/** Surface color for components like cards and menus in the dark theme. */ +val surfaceDark = Color(0xFF10140F) +/** Color used for text/icons on top of surfaces in the dark theme. */ +val onSurfaceDark = Color(0xFFE0E4DB) + +/** Pure black background for OLED screens in the dark theme. */ +val backgroundPureBlack = Color(0xFF000000) +/** Pure black surface for OLED screens in the dark theme. */ +val surfacePureBlack = Color(0xFF000000) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/Theme.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/Theme.kt new file mode 100644 index 0000000..5aafd22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/Theme.kt @@ -0,0 +1,114 @@ +package io.github.smiling_pixel.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +/** + * Material 3 light color scheme for the application. + */ +private val LightColorScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight +) + +/** + * Material 3 dark color scheme for the application. + */ +private val DarkColorScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark +) + +/** + * Material 3 dark color scheme for the application with pure black backgrounds for OLED screens. + */ +private val PureBlackColorScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundPureBlack, + onBackground = onBackgroundDark, + surface = surfacePureBlack, + onSurface = onSurfaceDark +) + +/** + * The main theme for the MarkDay diary app. + * + * This theme applies a green-based Material 3 color system to the application, providing + * support for light, dark, and pure black color schemes. It wraps the standard `MaterialTheme` + * composable from the Jetpack Compose Material 3 library. + * + * @param useDarkTheme Whether to use the dark theme color scheme. Defaults to system preference. + * @param isPureBlack Whether to use pure black backgrounds when in dark mode. Defaults to false. + * @param content The composable content that will be styled by this theme. + */ +@Composable +fun MarkDayTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + isPureBlack: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = if (useDarkTheme) { + if (isPureBlack) PureBlackColorScheme else DarkColorScheme + } else { + LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/ThemeMode.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/ThemeMode.kt new file mode 100644 index 0000000..2f5a5ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/theme/ThemeMode.kt @@ -0,0 +1,26 @@ +package io.github.smiling_pixel.theme + +/** + * Represents the different theme modes available in the application. + * + * This enum is used to determine which color palette (light or dark) + * should be applied to the user interface. + */ +enum class ThemeMode { + /** + * Follows the system's default theme settings. + * If the operating system is set to dark mode, the app will use the dark theme. + * Otherwise, it will default to the light theme. + */ + SYSTEM, + + /** + * Forces the application to use the light theme, regardless of the system settings. + */ + LIGHT, + + /** + * Forces the application to use the dark theme, regardless of the system settings. + */ + DARK +} diff --git a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerTest.kt index 64b78b3..97f51b5 100644 --- a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/sync/SyncManagerTest.kt @@ -325,12 +325,26 @@ class SyncManagerTest { } private class InMemorySettingsRepository : SettingsRepository { + private val themeModeState = MutableStateFlow(io.github.smiling_pixel.theme.ThemeMode.SYSTEM) + private val pureBlackEnabledState = MutableStateFlow(false) private val apiKeyState = MutableStateFlow(null) private val cloudSyncEnabledState = MutableStateFlow(false) private val autoSyncEnabledState = MutableStateFlow(false) private val cloudSyncPathState = MutableStateFlow("/MarkDay") private val cloudSyncDeletionTombstonesJsonState = MutableStateFlow(null) + override val themeMode: Flow = themeModeState.asStateFlow() + + override suspend fun setThemeMode(mode: io.github.smiling_pixel.theme.ThemeMode) { + themeModeState.value = mode + } + + override val isPureBlackEnabled: Flow = pureBlackEnabledState.asStateFlow() + + override suspend fun setPureBlackEnabled(enabled: Boolean) { + pureBlackEnabledState.value = enabled + } + override val googleWeatherApiKey: Flow = apiKeyState.asStateFlow() override suspend fun setGoogleWeatherApiKey(key: String?) { diff --git a/composeApp/src/nonWebMain/kotlin/io/github/smiling_pixel/preference/DataStoreSettingsRepository.kt b/composeApp/src/nonWebMain/kotlin/io/github/smiling_pixel/preference/DataStoreSettingsRepository.kt index 143c658..43cca0f 100644 --- a/composeApp/src/nonWebMain/kotlin/io/github/smiling_pixel/preference/DataStoreSettingsRepository.kt +++ b/composeApp/src/nonWebMain/kotlin/io/github/smiling_pixel/preference/DataStoreSettingsRepository.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.smiling_pixel.theme.ThemeMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import okio.Path.Companion.toPath @@ -30,6 +31,36 @@ class DataStoreSettingsRepository(private val dataStore: DataStore) private val IS_AUTO_SYNC_ENABLED = booleanPreferencesKey("is_auto_sync_enabled") private val CLOUD_SYNC_PATH = stringPreferencesKey("cloud_sync_path") private val CLOUD_SYNC_DELETION_TOMBSTONES_JSON = stringPreferencesKey("cloud_sync_deletion_tombstones_json") + private val THEME_MODE = stringPreferencesKey("theme_mode") + private val IS_PURE_BLACK_ENABLED = booleanPreferencesKey("is_pure_black_enabled") + + override val themeMode: Flow = dataStore.data + .map { preferences -> + preferences[THEME_MODE]?.let { modeString -> + try { + ThemeMode.valueOf(modeString) + } catch (e: IllegalArgumentException) { + ThemeMode.SYSTEM + } + } ?: ThemeMode.SYSTEM + } + + override suspend fun setThemeMode(mode: ThemeMode) { + dataStore.edit { preferences -> + preferences[THEME_MODE] = mode.name + } + } + + override val isPureBlackEnabled: Flow = dataStore.data + .map { preferences -> + preferences[IS_PURE_BLACK_ENABLED] ?: false + } + + override suspend fun setPureBlackEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[IS_PURE_BLACK_ENABLED] = enabled + } + } override val googleWeatherApiKey: Flow = dataStore.data .map { preferences -> diff --git a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.wasmJs.kt index b26f99d..05ca44d 100644 --- a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/preference/SettingsRepository.wasmJs.kt @@ -1,5 +1,6 @@ package io.github.smiling_pixel.preference +import io.github.smiling_pixel.theme.ThemeMode import kotlinx.browser.localStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -12,6 +13,30 @@ class WasmJsSettingsRepository : SettingsRepository { private val _cloudSyncPath = MutableStateFlow(localStorage.getItem("cloud_sync_path") ?: "/MarkDay") private val _cloudSyncDeletionTombstonesJson = MutableStateFlow(localStorage.getItem("cloud_sync_deletion_tombstones_json")) + private val _themeMode = MutableStateFlow( + localStorage.getItem("theme_mode")?.let { + try { + ThemeMode.valueOf(it) + } catch (e: IllegalArgumentException) { + ThemeMode.SYSTEM + } + } ?: ThemeMode.SYSTEM + ) + private val _isPureBlackEnabled = MutableStateFlow(localStorage.getItem("is_pure_black_enabled") == "true") + + override val themeMode: Flow = _themeMode.asStateFlow() + + override suspend fun setThemeMode(mode: ThemeMode) { + localStorage.setItem("theme_mode", mode.name) + _themeMode.value = mode + } + + override val isPureBlackEnabled: Flow = _isPureBlackEnabled.asStateFlow() + + override suspend fun setPureBlackEnabled(enabled: Boolean) { + localStorage.setItem("is_pure_black_enabled", enabled.toString()) + _isPureBlackEnabled.value = enabled + } override val googleWeatherApiKey: Flow = _apiKey.asStateFlow() @@ -58,4 +83,6 @@ class WasmJsSettingsRepository : SettingsRepository { } } -actual fun getSettingsRepository(): SettingsRepository = WasmJsSettingsRepository() +private val repositoryInstance by lazy { WasmJsSettingsRepository() } + +actual fun getSettingsRepository(): SettingsRepository = repositoryInstance