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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FeedPost?>(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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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