Feature: Settings screen with account, theme, profile, reset + credits

Backend:
- New GET/PUT /api/me endpoint to read and update the authenticated
  user's profile (currently display_name). UserStore.UpdateDisplayName.
- Wired into the authed router.

App:
- New SettingsScreen + SettingsViewModel with five sections:
  * Account & Sync: login status, email, server URL, logout, connect.
  * Profile: edit display name (pushed to PUT /api/me when logged in).
  * Appearance: System / Light / Dark theme switch, persisted in
    SessionManager and applied via MitbringslTheme(sessionManager).
  * Data: 'Reset local data' wipes Room tables (server data kept).
  * About: version, 'Developed by Janik Dietz', and credits to
    GLM-5.2 + Gemini 3.6 Flash.
- Theme.kt now reads the user's theme preference (StateFlow) instead
  of only the system default; MainActivity passes SessionManager in.
- Navigation: new SettingsNavKey; settings gear icon in ListsScreen
  top bar; Settings links back to the Sync/Account screen.
- DTOs/API: UpdateMeRequestDto + getMe()/updateMe() for /api/me.
This commit is contained in:
Tronax 2026-08-06 10:39:12 +02:00
parent 3f187f1ede
commit 729865be78
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
13 changed files with 717 additions and 12 deletions

View file

@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
MitbringslTheme {
MitbringslTheme(sessionManager = sessionManager) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background

View file

@ -8,6 +8,7 @@ import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.ui.auth.AuthScreen
import com.example.mitbringsl.ui.detail.ListDetailScreen
import com.example.mitbringsl.ui.lists.ListsScreen
import com.example.mitbringsl.ui.settings.SettingsScreen
@Composable
fun MainNavigation(sessionManager: SessionManager) {
@ -25,6 +26,9 @@ fun MainNavigation(sessionManager: SessionManager) {
},
onOpenSync = {
backStack.add(AuthNavKey)
},
onOpenSettings = {
backStack.add(SettingsNavKey)
}
)
}
@ -35,6 +39,14 @@ fun MainNavigation(sessionManager: SessionManager) {
}
)
}
entry<SettingsNavKey> {
SettingsScreen(
onBack = { backStack.removeLastOrNull() },
onOpenSync = {
backStack.add(AuthNavKey)
}
)
}
entry<ListDetailNavKey> { key ->
ListDetailScreen(
listId = key.listId,

View file

@ -5,4 +5,5 @@ import kotlinx.serialization.Serializable
@Serializable data object AuthNavKey : NavKey
@Serializable data object ListsNavKey : NavKey
@Serializable data object SettingsNavKey : NavKey
@Serializable data class ListDetailNavKey(val listId: String) : NavKey

View file

@ -29,6 +29,17 @@ class SessionManager @Inject constructor(
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
// Theme preference: "system" | "light" | "dark"
private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
fun getThemeMode(): String = prefs.getString("theme_mode", null) ?: "system"
fun saveThemeMode(mode: String) {
prefs.edit { putString("theme_mode", mode) }
_themeMode.value = mode
}
fun getServerUrl(): String {
return prefs.getString("server_url", null) ?: BuildConfig.BASE_URL
}

View file

@ -27,6 +27,14 @@ interface MitbringslApi {
@POST("auth/logout")
suspend fun logout(): Response<Unit>
// --- Profile (/api/me) --------------------------------------------------
@GET("api/me")
suspend fun getMe(): Response<UserDto>
@PUT("api/me")
suspend fun updateMe(@Body body: UpdateMeRequestDto): Response<UserDto>
// --- Lists --------------------------------------------------------------
@GET("api/lists")

View file

@ -41,6 +41,11 @@ data class UserDto(
@SerialName("display_name") val displayName: String = "",
)
@Serializable
data class UpdateMeRequestDto(
@SerialName("display_name") val displayName: String? = null,
)
// ---------------------------------------------------------------------------
// Lists
// ---------------------------------------------------------------------------

View file

@ -8,7 +8,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import com.example.mitbringsl.data.auth.SessionManager
private val DarkColorScheme = darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80)
@ -17,25 +20,27 @@ private val LightColorScheme =
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
/**
* App theme. The dark/light mode is driven by the user's preference stored in
* [SessionManager] ("system" | "light" | "dark"), defaulting to the system setting.
*/
@Composable
fun MitbringslTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
sessionManager: SessionManager,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val mode by sessionManager.themeMode.collectAsState()
val systemDark = isSystemInDarkTheme()
val darkTheme = when (mode) {
"light" -> false
"dark" -> true
else -> systemDark // "system" or any unknown value
}
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {

View file

@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.*
@ -33,6 +34,7 @@ import com.example.mitbringsl.data.local.entity.ListEntity
fun ListsScreen(
onSelectList: (String) -> Unit,
onOpenSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
@ -56,6 +58,12 @@ fun ListsScreen(
)
},
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Einstellungen"
)
}
IconButton(onClick = {
if (isLoggedIn) {
showJoinDialog = true

View file

@ -0,0 +1,410 @@
package com.example.mitbringsl.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
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.AutoAwesome
import androidx.compose.material.icons.filled.Brightness6
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onOpenSync: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
val currentServerUrl by viewModel.sessionManager.serverUrl.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail()
val themeMode by viewModel.sessionManager.themeMode.collectAsStateWithLifecycle()
var showResetDialog by remember { mutableStateOf(false) }
var showLogoutDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Einstellungen", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
modifier = modifier
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// --- Account / Sync -------------------------------------------------
SectionHeader(text = "Account & Sync")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
if (isLoggedIn) {
ProfileRow(label = "Angemeldet als", value = userEmail ?: "")
ProfileRow(
label = "Anzeigename",
value = uiState.serverDisplayName.ifBlank { "" }
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onOpenSync,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Icon(imageVector = Icons.Default.Dns, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Server-Verbindung verwalten")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(imageVector = Icons.Default.CloudOff, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Abmelden")
}
} else {
Text(
text = "Nicht verbunden",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Server: $currentServerUrl",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onOpenSync,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Text("Mit Server verbinden")
}
}
}
}
// --- Profil (Display Name) -----------------------------------------
if (isLoggedIn) {
SectionHeader(text = "Profil")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = uiState.displayNameInput,
onValueChange = viewModel::onDisplayNameChanged,
label = { Text("Anzeigename") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isSaving
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = viewModel::saveDisplayName,
enabled = !uiState.isSaving && uiState.displayNameInput != uiState.serverDisplayName,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
if (uiState.isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(imageVector = Icons.Default.Person, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Speichern")
}
}
}
}
}
// --- Theme ---------------------------------------------------------
SectionHeader(text = "Erscheinungsbild")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.Brightness6, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text("Design", style = MaterialTheme.typography.bodyLarge)
}
Spacer(modifier = Modifier.height(12.dp))
ThemeOptionRow(
label = "System",
selected = themeMode == "system",
onClick = { viewModel.setThemeMode("system") }
)
ThemeOptionRow(
label = "Hell",
selected = themeMode == "light",
onClick = { viewModel.setThemeMode("light") }
)
ThemeOptionRow(
label = "Dunkel",
selected = themeMode == "dark",
onClick = { viewModel.setThemeMode("dark") }
)
}
}
// --- Daten zurücksetzen --------------------------------------------
SectionHeader(text = "Daten")
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Lokale Daten löschen",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Entfernt alle Listen und Items von diesem Gerät. Server-Daten bleiben erhalten und können neu synchronisiert werden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { showResetDialog = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(imageVector = Icons.Default.DeleteForever, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Lokale Daten zurücksetzen")
}
}
}
// --- Über / Credits ------------------------------------------------
SectionHeader(text = "Über Mitbringsl")
Card(shape = RoundedCornerShape(16.dp)) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Mitbringsl",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Version 1.0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Entwickelt von",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Janik Dietz",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Mit Unterstützung durch",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
CreditItem(text = "GLM-5.2")
CreditItem(text = "Gemini 3.6 Flash")
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Local-First Einkaufslisten-App werbefrei, selbstgehostet.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// --- message / error toast-like ------------------------------------
if (uiState.message != null) {
Snackbar { Text(uiState.message!!) }
}
if (uiState.error != null) {
Snackbar(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
) { Text(uiState.error!!) }
}
Spacer(modifier = Modifier.height(24.dp))
}
}
// --- dialogs -------------------------------------------------------------
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Lokale Daten löschen?") },
text = {
Text(
"Alle Listen und Items werden von diesem Gerät entfernt. " +
"Diese Aktion kann nicht rückgängig gemacht werden. " +
"Auf dem Server gespeicherte Daten bleiben erhalten."
)
},
confirmButton = {
Button(
onClick = {
viewModel.resetLocalData()
showResetDialog = false
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
shape = RoundedCornerShape(12.dp)
) {
Text("Löschen")
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) { Text("Abbrechen") }
},
shape = RoundedCornerShape(24.dp)
)
}
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Abmelden?") },
text = {
Text(
"Du wirst von diesem Gerät abgemeldet. " +
"Lokale Änderungen, die noch nicht synchronisiert wurden, gehen verloren."
)
},
confirmButton = {
Button(
onClick = {
viewModel.logout()
showLogoutDialog = false
},
shape = RoundedCornerShape(12.dp)
) {
Text("Abmelden")
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen") }
},
shape = RoundedCornerShape(24.dp)
)
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
}
@Composable
private fun ProfileRow(label: String, value: String) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun ThemeOptionRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = onClick)
Spacer(modifier = Modifier.width(8.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun CreditItem(text: String) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
shape = RoundedCornerShape(20.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
)
}
}

View file

@ -0,0 +1,144 @@
package com.example.mitbringsl.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.mitbringsl.data.auth.AuthRepository
import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.local.AppDatabase
import com.example.mitbringsl.data.remote.api.MitbringslApi
import com.example.mitbringsl.data.remote.dto.UpdateMeRequestDto
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val displayNameInput: String = "",
val serverDisplayName: String = "",
val message: String? = null,
val error: String? = null,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val api: MitbringslApi,
private val authRepository: AuthRepository,
private val db: AppDatabase,
val sessionManager: SessionManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadProfile()
}
/** Loads the display name from the server (if logged in) or falls back to local. */
fun loadProfile() {
if (!sessionManager.isLoggedIn.value) {
_uiState.update { it.copy(displayNameInput = "", serverDisplayName = "") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val resp = api.getMe()
if (resp.isSuccessful && resp.body() != null) {
val name = resp.body()!!.displayName
_uiState.update {
it.copy(
isLoading = false,
displayNameInput = name,
serverDisplayName = name,
)
}
} else {
_uiState.update {
it.copy(isLoading = false, error = "Profil konnte nicht geladen werden.")
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.localizedMessage ?: "Netzwerkfehler")
}
}
}
}
fun onDisplayNameChanged(value: String) {
_uiState.update { it.copy(displayNameInput = value, message = null, error = null) }
}
/** Pushes the display name to the server (only when logged in). */
fun saveDisplayName() {
if (!sessionManager.isLoggedIn.value) {
_uiState.update { it.copy(message = "Nicht verbunden Änderung nur lokal möglich.") }
return
}
val name = _uiState.value.displayNameInput.trim()
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, error = null, message = null) }
try {
val resp = api.updateMe(UpdateMeRequestDto(displayName = name))
if (resp.isSuccessful && resp.body() != null) {
_uiState.update {
it.copy(
isSaving = false,
serverDisplayName = resp.body()!!.displayName,
message = "Anzeigename gespeichert.",
)
}
} else {
_uiState.update {
it.copy(isSaving = false, error = "Speichern fehlgeschlagen (HTTP ${resp.code()}).")
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(isSaving = false, error = e.localizedMessage ?: "Netzwerkfehler")
}
}
}
}
fun setThemeMode(mode: String) {
sessionManager.saveThemeMode(mode)
}
/** Logs the user out. */
fun logout() {
viewModelScope.launch {
authRepository.logout()
}
}
/**
* Wipes all local data (lists, items, op_log) the destructive reset.
* Server data is NOT affected; the user can re-sync after logging in again.
*/
fun resetLocalData() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
db.clearAllTables()
_uiState.update {
it.copy(isLoading = false, message = "Lokale Daten wurden gelöscht.")
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.localizedMessage ?: "Löschen fehlgeschlagen.")
}
}
}
}
fun clearMessage() {
_uiState.update { it.copy(message = null, error = null) }
}
}