mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add dark/light mode toggle with system/light/dark options
This commit is contained in:
parent
74f42fd2f1
commit
fe60d17d39
10 changed files with 354 additions and 7 deletions
|
|
@ -4,13 +4,20 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.CredentialsManager
|
||||||
import com.swoosh.microblog.ui.navigation.Routes
|
import com.swoosh.microblog.ui.navigation.Routes
|
||||||
import com.swoosh.microblog.ui.navigation.SwooshNavGraph
|
import com.swoosh.microblog.ui.navigation.SwooshNavGraph
|
||||||
import com.swoosh.microblog.ui.theme.SwooshTheme
|
import com.swoosh.microblog.ui.theme.SwooshTheme
|
||||||
|
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val themeViewModel: ThemeViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
@ -19,11 +26,14 @@ class MainActivity : ComponentActivity() {
|
||||||
val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP
|
val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SwooshTheme {
|
val themeMode by themeViewModel.themeMode.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
SwooshTheme(themeMode = themeMode) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
SwooshNavGraph(
|
SwooshNavGraph(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination
|
startDestination = startDestination,
|
||||||
|
themeViewModel = themeViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.WifiOff
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
|
|
@ -28,6 +31,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -35,9 +40,11 @@ fun FeedScreen(
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onPostClick: (FeedPost) -> Unit,
|
onPostClick: (FeedPost) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
viewModel: FeedViewModel = viewModel()
|
viewModel: FeedViewModel = viewModel(),
|
||||||
|
themeViewModel: ThemeViewModel? = null
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val themeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
|
|
@ -65,6 +72,17 @@ fun FeedScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Swoosh") },
|
title = { Text("Swoosh") },
|
||||||
actions = {
|
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() }) {
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.swoosh.microblog.ui.feed.FeedScreen
|
||||||
import com.swoosh.microblog.ui.feed.FeedViewModel
|
import com.swoosh.microblog.ui.feed.FeedViewModel
|
||||||
import com.swoosh.microblog.ui.settings.SettingsScreen
|
import com.swoosh.microblog.ui.settings.SettingsScreen
|
||||||
import com.swoosh.microblog.ui.setup.SetupScreen
|
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||||
|
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val SETUP = "setup"
|
const val SETUP = "setup"
|
||||||
|
|
@ -25,7 +26,8 @@ object Routes {
|
||||||
@Composable
|
@Composable
|
||||||
fun SwooshNavGraph(
|
fun SwooshNavGraph(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
startDestination: String
|
startDestination: String,
|
||||||
|
themeViewModel: ThemeViewModel
|
||||||
) {
|
) {
|
||||||
// Shared state for passing posts between screens
|
// Shared state for passing posts between screens
|
||||||
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
|
|
@ -47,6 +49,7 @@ fun SwooshNavGraph(
|
||||||
composable(Routes.FEED) {
|
composable(Routes.FEED) {
|
||||||
FeedScreen(
|
FeedScreen(
|
||||||
viewModel = feedViewModel,
|
viewModel = feedViewModel,
|
||||||
|
themeViewModel = themeViewModel,
|
||||||
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
||||||
onPostClick = { post ->
|
onPostClick = { post ->
|
||||||
selectedPost = post
|
selectedPost = post
|
||||||
|
|
@ -91,6 +94,7 @@ fun SwooshNavGraph(
|
||||||
|
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
|
themeViewModel = themeViewModel,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate(Routes.SETUP) {
|
navController.navigate(Routes.SETUP) {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,34 @@
|
||||||
package com.swoosh.microblog.ui.settings
|
package com.swoosh.microblog.ui.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.CredentialsManager
|
||||||
import com.swoosh.microblog.data.api.ApiClient
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
|
import com.swoosh.microblog.ui.theme.ThemeMode
|
||||||
|
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit
|
onLogout: () -> Unit,
|
||||||
|
themeViewModel: ThemeViewModel? = null
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val credentials = remember { CredentialsManager(context) }
|
val credentials = remember { CredentialsManager(context) }
|
||||||
|
|
@ -26,6 +36,8 @@ fun SettingsScreen(
|
||||||
var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") }
|
var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") }
|
||||||
var saved by remember { mutableStateOf(false) }
|
var saved by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -43,7 +55,24 @@ fun SettingsScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp)
|
.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)
|
Text("Ghost Instance", style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
|
@ -91,7 +120,7 @@ fun SettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
OutlinedButton(
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,16 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SwooshTheme(
|
fun SwooshTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val darkTheme = when (themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
|
||||||
19
app/src/main/java/com/swoosh/microblog/ui/theme/ThemeMode.kt
Normal file
19
app/src/main/java/com/swoosh/microblog/ui/theme/ThemeMode.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> = _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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue