Merge branch 'worktree-agent-a3aee2cc' into claude/ghost-microblog-android-utau1

This commit is contained in:
Paweł Orzech 2026-03-19 10:37:27 +01:00
commit 797c6eedd0
No known key found for this signature in database
10 changed files with 354 additions and 7 deletions

View file

@ -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
)
}
}

View file

@ -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")
}

View file

@ -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) }
@ -48,6 +50,7 @@ fun SwooshNavGraph(
composable(Routes.FEED) {
FeedScreen(
viewModel = feedViewModel,
themeViewModel = themeViewModel,
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
onPostClick = { post ->
selectedPost = post
@ -92,6 +95,7 @@ fun SwooshNavGraph(
composable(Routes.SETTINGS) {
SettingsScreen(
themeViewModel = themeViewModel,
onBack = { navController.popBackStack() },
onLogout = {
navController.navigate(Routes.SETUP) {

View file

@ -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)
)
}
}
}

View file

@ -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

View 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
}
}

View file

@ -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"
}
}

View file

@ -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())
}
}

View file

@ -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())
}
}

View file

@ -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)
}
}
}