From fe60d17d39cc9bd100cd7eac745570a2bb1b8910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:37:00 +0100 Subject: [PATCH] feat: add dark/light mode toggle with system/light/dark options --- .../java/com/swoosh/microblog/MainActivity.kt | 14 ++- .../swoosh/microblog/ui/feed/FeedScreen.kt | 20 +++- .../microblog/ui/navigation/NavGraph.kt | 6 +- .../microblog/ui/settings/SettingsScreen.kt | 67 +++++++++++++- .../com/swoosh/microblog/ui/theme/Theme.kt | 8 +- .../swoosh/microblog/ui/theme/ThemeMode.kt | 19 ++++ .../microblog/ui/theme/ThemePreferences.kt | 34 +++++++ .../microblog/ui/theme/ThemeViewModel.kt | 31 +++++++ .../microblog/ui/theme/ThemeModeTest.kt | 70 ++++++++++++++ .../ui/theme/ThemePreferencesTest.kt | 92 +++++++++++++++++++ 10 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/swoosh/microblog/ui/theme/ThemeMode.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/theme/ThemePreferences.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/theme/ThemeViewModel.kt create mode 100644 app/src/test/java/com/swoosh/microblog/ui/theme/ThemeModeTest.kt create mode 100644 app/src/test/java/com/swoosh/microblog/ui/theme/ThemePreferencesTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/MainActivity.kt b/app/src/main/java/com/swoosh/microblog/MainActivity.kt index da32a06..7554bfc 100644 --- a/app/src/main/java/com/swoosh/microblog/MainActivity.kt +++ b/app/src/main/java/com/swoosh/microblog/MainActivity.kt @@ -4,13 +4,20 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue import androidx.navigation.compose.rememberNavController import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.ui.navigation.Routes import com.swoosh.microblog.ui.navigation.SwooshNavGraph import com.swoosh.microblog.ui.theme.SwooshTheme +import com.swoosh.microblog.ui.theme.ThemeViewModel class MainActivity : ComponentActivity() { + + private val themeViewModel: ThemeViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -19,11 +26,14 @@ class MainActivity : ComponentActivity() { val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP setContent { - SwooshTheme { + val themeMode by themeViewModel.themeMode.collectAsStateWithLifecycle() + + SwooshTheme(themeMode = themeMode) { val navController = rememberNavController() SwooshNavGraph( navController = navController, - startDestination = startDestination + startDestination = startDestination, + themeViewModel = themeViewModel ) } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..661679f 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -8,6 +8,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.WifiOff @@ -28,6 +31,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.QueueStatus +import com.swoosh.microblog.ui.theme.ThemeMode +import com.swoosh.microblog.ui.theme.ThemeViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable @@ -35,9 +40,11 @@ fun FeedScreen( onSettingsClick: () -> Unit, onPostClick: (FeedPost) -> Unit, onCompose: () -> Unit, - viewModel: FeedViewModel = viewModel() + viewModel: FeedViewModel = viewModel(), + themeViewModel: ThemeViewModel? = null ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() val listState = rememberLazyListState() // Pull-to-refresh @@ -65,6 +72,17 @@ fun FeedScreen( TopAppBar( title = { Text("Swoosh") }, actions = { + if (themeViewModel != null) { + val currentMode = themeMode?.value ?: ThemeMode.SYSTEM + val (icon, description) = when (currentMode) { + ThemeMode.SYSTEM -> Icons.Default.BrightnessAuto to "Theme: System" + ThemeMode.LIGHT -> Icons.Default.LightMode to "Theme: Light" + ThemeMode.DARK -> Icons.Default.DarkMode to "Theme: Dark" + } + IconButton(onClick = { themeViewModel.cycleThemeMode() }) { + Icon(icon, contentDescription = description) + } + } IconButton(onClick = { viewModel.refresh() }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index a33cc63..043b5c1 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -13,6 +13,7 @@ import com.swoosh.microblog.ui.feed.FeedScreen import com.swoosh.microblog.ui.feed.FeedViewModel import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.setup.SetupScreen +import com.swoosh.microblog.ui.theme.ThemeViewModel object Routes { const val SETUP = "setup" @@ -25,7 +26,8 @@ object Routes { @Composable fun SwooshNavGraph( navController: NavHostController, - startDestination: String + startDestination: String, + themeViewModel: ThemeViewModel ) { // Shared state for passing posts between screens var selectedPost by remember { mutableStateOf(null) } @@ -47,6 +49,7 @@ fun SwooshNavGraph( composable(Routes.FEED) { FeedScreen( viewModel = feedViewModel, + themeViewModel = themeViewModel, onSettingsClick = { navController.navigate(Routes.SETTINGS) }, onPostClick = { post -> selectedPost = post @@ -91,6 +94,7 @@ fun SwooshNavGraph( composable(Routes.SETTINGS) { SettingsScreen( + themeViewModel = themeViewModel, onBack = { navController.popBackStack() }, onLogout = { navController.navigate(Routes.SETUP) { diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index dac1611..770e9fd 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -1,24 +1,34 @@ package com.swoosh.microblog.ui.settings import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.api.ApiClient +import com.swoosh.microblog.ui.theme.ThemeMode +import com.swoosh.microblog.ui.theme.ThemeViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( onBack: () -> Unit, - onLogout: () -> Unit + onLogout: () -> Unit, + themeViewModel: ThemeViewModel? = null ) { val context = LocalContext.current val credentials = remember { CredentialsManager(context) } @@ -26,6 +36,8 @@ fun SettingsScreen( var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") } var saved by remember { mutableStateOf(false) } + val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() + Scaffold( topBar = { TopAppBar( @@ -43,7 +55,24 @@ fun SettingsScreen( .fillMaxSize() .padding(padding) .padding(24.dp) + .verticalScroll(rememberScrollState()) ) { + // --- Appearance section --- + if (themeViewModel != null) { + Text("Appearance", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + + ThemeModeSelector( + currentMode = currentThemeMode?.value ?: ThemeMode.SYSTEM, + onModeSelected = { themeViewModel.setThemeMode(it) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(24.dp)) + } + + // --- Ghost Instance section --- Text("Ghost Instance", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(16.dp)) @@ -91,7 +120,7 @@ fun SettingsScreen( } Spacer(modifier = Modifier.height(32.dp)) - Divider() + HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) OutlinedButton( @@ -110,3 +139,37 @@ fun SettingsScreen( } } } + +@Composable +fun ThemeModeSelector( + currentMode: ThemeMode, + onModeSelected: (ThemeMode) -> Unit +) { + val modes = listOf( + Triple(ThemeMode.SYSTEM, "System", Icons.Default.BrightnessAuto), + Triple(ThemeMode.LIGHT, "Light", Icons.Default.LightMode), + Triple(ThemeMode.DARK, "Dark", Icons.Default.DarkMode) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + modes.forEach { (mode, label, icon) -> + val selected = currentMode == mode + FilterChip( + selected = selected, + onClick = { onModeSelected(mode) }, + label = { Text(label) }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + }, + modifier = Modifier.weight(1f) + ) + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt index 4d97b55..b2885fd 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt @@ -8,10 +8,16 @@ import androidx.compose.ui.platform.LocalContext @Composable fun SwooshTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + themeMode: ThemeMode = ThemeMode.SYSTEM, dynamicColor: Boolean = true, content: @Composable () -> Unit ) { + val darkTheme = when (themeMode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/ThemeMode.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/ThemeMode.kt new file mode 100644 index 0000000..746a71c --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/theme/ThemeMode.kt @@ -0,0 +1,19 @@ +package com.swoosh.microblog.ui.theme + +/** + * Represents the user's theme preference. + */ +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK; + + /** + * Returns the next mode in the cycle: SYSTEM -> LIGHT -> DARK -> SYSTEM. + */ + fun next(): ThemeMode = when (this) { + SYSTEM -> LIGHT + LIGHT -> DARK + DARK -> SYSTEM + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/ThemePreferences.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/ThemePreferences.kt new file mode 100644 index 0000000..d11207e --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/theme/ThemePreferences.kt @@ -0,0 +1,34 @@ +package com.swoosh.microblog.ui.theme + +import android.content.Context +import android.content.SharedPreferences + +/** + * Persists the user's theme preference to SharedPreferences. + * Uses regular SharedPreferences (not encrypted) since theme preference is not sensitive. + */ +class ThemePreferences(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + var themeMode: ThemeMode + get() { + val name = prefs.getString(KEY_THEME_MODE, ThemeMode.SYSTEM.name) + return try { + ThemeMode.valueOf(name ?: ThemeMode.SYSTEM.name) + } catch (e: IllegalArgumentException) { + ThemeMode.SYSTEM + } + } + set(value) { + prefs.edit().putString(KEY_THEME_MODE, value.name).apply() + } + + companion object { + const val PREFS_NAME = "swoosh_theme_prefs" + const val KEY_THEME_MODE = "theme_mode" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/ThemeViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/ThemeViewModel.kt new file mode 100644 index 0000000..7b1d49f --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/theme/ThemeViewModel.kt @@ -0,0 +1,31 @@ +package com.swoosh.microblog.ui.theme + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * App-level ViewModel that manages theme mode state. + * Should be scoped to the Activity so all screens share the same instance. + */ +class ThemeViewModel(application: Application) : AndroidViewModel(application) { + + private val themePreferences = ThemePreferences(application) + + private val _themeMode = MutableStateFlow(themePreferences.themeMode) + val themeMode: StateFlow = _themeMode.asStateFlow() + + fun setThemeMode(mode: ThemeMode) { + themePreferences.themeMode = mode + _themeMode.value = mode + } + + /** + * Cycles through modes: SYSTEM -> LIGHT -> DARK -> SYSTEM. + */ + fun cycleThemeMode() { + setThemeMode(_themeMode.value.next()) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/ui/theme/ThemeModeTest.kt b/app/src/test/java/com/swoosh/microblog/ui/theme/ThemeModeTest.kt new file mode 100644 index 0000000..e96ae53 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/ui/theme/ThemeModeTest.kt @@ -0,0 +1,70 @@ +package com.swoosh.microblog.ui.theme + +import org.junit.Assert.* +import org.junit.Test + +class ThemeModeTest { + + @Test + fun `ThemeMode has exactly 3 values`() { + assertEquals(3, ThemeMode.values().size) + } + + @Test + fun `ThemeMode values are SYSTEM, LIGHT, DARK`() { + val values = ThemeMode.values().toList() + assertEquals(ThemeMode.SYSTEM, values[0]) + assertEquals(ThemeMode.LIGHT, values[1]) + assertEquals(ThemeMode.DARK, values[2]) + } + + @Test + fun `ThemeMode valueOf works for all values`() { + assertEquals(ThemeMode.SYSTEM, ThemeMode.valueOf("SYSTEM")) + assertEquals(ThemeMode.LIGHT, ThemeMode.valueOf("LIGHT")) + assertEquals(ThemeMode.DARK, ThemeMode.valueOf("DARK")) + } + + @Test(expected = IllegalArgumentException::class) + fun `ThemeMode valueOf throws for invalid value`() { + ThemeMode.valueOf("INVALID") + } + + // --- Cycling logic --- + + @Test + fun `next from SYSTEM returns LIGHT`() { + assertEquals(ThemeMode.LIGHT, ThemeMode.SYSTEM.next()) + } + + @Test + fun `next from LIGHT returns DARK`() { + assertEquals(ThemeMode.DARK, ThemeMode.LIGHT.next()) + } + + @Test + fun `next from DARK returns SYSTEM`() { + assertEquals(ThemeMode.SYSTEM, ThemeMode.DARK.next()) + } + + @Test + fun `cycling through all modes returns to start`() { + val start = ThemeMode.SYSTEM + val afterFirst = start.next() + val afterSecond = afterFirst.next() + val afterThird = afterSecond.next() + assertEquals(start, afterThird) + } + + @Test + fun `full cycle from LIGHT returns to LIGHT`() { + val start = ThemeMode.LIGHT + assertEquals(start, start.next().next().next()) + } + + @Test + fun `full cycle from DARK returns to DARK`() { + val start = ThemeMode.DARK + assertEquals(start, start.next().next().next()) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/ui/theme/ThemePreferencesTest.kt b/app/src/test/java/com/swoosh/microblog/ui/theme/ThemePreferencesTest.kt new file mode 100644 index 0000000..85b8a34 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/ui/theme/ThemePreferencesTest.kt @@ -0,0 +1,92 @@ +package com.swoosh.microblog.ui.theme + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class ThemePreferencesTest { + + private lateinit var context: Context + private lateinit var themePreferences: ThemePreferences + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + // Clear prefs before each test + context.getSharedPreferences(ThemePreferences.PREFS_NAME, Context.MODE_PRIVATE) + .edit().clear().commit() + themePreferences = ThemePreferences(context) + } + + @Test + fun `default theme mode is SYSTEM`() { + assertEquals(ThemeMode.SYSTEM, themePreferences.themeMode) + } + + @Test + fun `set and get LIGHT mode`() { + themePreferences.themeMode = ThemeMode.LIGHT + assertEquals(ThemeMode.LIGHT, themePreferences.themeMode) + } + + @Test + fun `set and get DARK mode`() { + themePreferences.themeMode = ThemeMode.DARK + assertEquals(ThemeMode.DARK, themePreferences.themeMode) + } + + @Test + fun `set and get SYSTEM mode`() { + themePreferences.themeMode = ThemeMode.DARK + themePreferences.themeMode = ThemeMode.SYSTEM + assertEquals(ThemeMode.SYSTEM, themePreferences.themeMode) + } + + @Test + fun `persists across instances`() { + themePreferences.themeMode = ThemeMode.DARK + val newInstance = ThemePreferences(context) + assertEquals(ThemeMode.DARK, newInstance.themeMode) + } + + @Test + fun `overwriting mode replaces previous value`() { + themePreferences.themeMode = ThemeMode.LIGHT + themePreferences.themeMode = ThemeMode.DARK + assertEquals(ThemeMode.DARK, themePreferences.themeMode) + } + + @Test + fun `invalid stored value falls back to SYSTEM`() { + // Write an invalid value directly to SharedPreferences + context.getSharedPreferences(ThemePreferences.PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(ThemePreferences.KEY_THEME_MODE, "INVALID_VALUE").commit() + val prefs = ThemePreferences(context) + assertEquals(ThemeMode.SYSTEM, prefs.themeMode) + } + + @Test + fun `null stored value falls back to SYSTEM`() { + // Remove the key from SharedPreferences + context.getSharedPreferences(ThemePreferences.PREFS_NAME, Context.MODE_PRIVATE) + .edit().remove(ThemePreferences.KEY_THEME_MODE).commit() + val prefs = ThemePreferences(context) + assertEquals(ThemeMode.SYSTEM, prefs.themeMode) + } + + @Test + fun `all modes can be round-tripped`() { + for (mode in ThemeMode.values()) { + themePreferences.themeMode = mode + assertEquals(mode, themePreferences.themeMode) + } + } +}