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:
parent
3f187f1ede
commit
729865be78
13 changed files with 717 additions and 12 deletions
|
|
@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MitbringslTheme {
|
||||
MitbringslTheme(sessionManager = sessionManager) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +114,27 @@ func (s *UserStore) GetByOIDCSubject(ctx context.Context, issuer, subject string
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateDisplayName sets the display_name of the user with the given id.
|
||||
// An empty displayName clears the field (stores NULL).
|
||||
func (s *UserStore) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) (User, error) {
|
||||
const q = `
|
||||
UPDATE users SET display_name = NULLIF($2, ''), updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, oidc_subject, oidc_issuer, display_name`
|
||||
var u User
|
||||
var dn *string
|
||||
err := s.pool.QueryRow(ctx, q, id, displayName).
|
||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
return User{}, fmt.Errorf("update display name: %w", err)
|
||||
}
|
||||
u.DisplayName = dn
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// CreateOIDCUser inserts a new user for an OIDC login. The user has no password
|
||||
// (password_hash is NULL) and is identified by (issuer, subject). email may be
|
||||
// empty if the IdP did not provide one; we store a synthesized placeholder so
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type API struct {
|
|||
ops *OpsHandler
|
||||
suggest *SuggestHandler
|
||||
config *ConfigHandler
|
||||
me *MeHandler
|
||||
}
|
||||
|
||||
// NewAPI constructs the API with all handler groups.
|
||||
|
|
@ -51,6 +52,7 @@ func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API {
|
|||
ops: NewOpsHandler(opStore, listStore),
|
||||
suggest: NewSuggestHandler(suggestStore),
|
||||
config: NewConfigHandler(cfg),
|
||||
me: NewMeHandler(users),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +87,8 @@ func (a *API) Handler() http.Handler {
|
|||
mux.HandleFunc("POST /auth/logout", a.auth.Logout)
|
||||
|
||||
// --- authenticated API endpoints ---
|
||||
mux.Handle("GET /api/me", a.RequireAuth(http.HandlerFunc(a.me.Get)))
|
||||
mux.Handle("PUT /api/me", a.RequireAuth(http.HandlerFunc(a.me.Update)))
|
||||
mux.Handle("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List)))
|
||||
mux.Handle("POST /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.Create)))
|
||||
mux.Handle("POST /api/lists/join", a.RequireAuth(http.HandlerFunc(a.lists.Join)))
|
||||
|
|
|
|||
76
backend/internal/httpapi/me.go
Normal file
76
backend/internal/httpapi/me.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/auth"
|
||||
)
|
||||
|
||||
// MeHandler exposes the authenticated user's own profile (/api/me).
|
||||
type MeHandler struct {
|
||||
users *auth.UserStore
|
||||
}
|
||||
|
||||
// NewMeHandler constructs a MeHandler.
|
||||
func NewMeHandler(users *auth.UserStore) *MeHandler {
|
||||
return &MeHandler{users: users}
|
||||
}
|
||||
|
||||
type updateMeRequest struct {
|
||||
DisplayName *string `json:"display_name"` // nil = unchanged, "" = clear
|
||||
}
|
||||
|
||||
// Get returns the current user's profile.
|
||||
// GET /api/me
|
||||
func (h *MeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := userIDFromCtx(r)
|
||||
if !ok {
|
||||
renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.")
|
||||
return
|
||||
}
|
||||
u, err := h.users.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("get me failed", "error", err, "user_id", userID)
|
||||
renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.")
|
||||
return
|
||||
}
|
||||
renderJSON(w, http.StatusOK, toUserDTO(u))
|
||||
}
|
||||
|
||||
// Update changes editable fields of the current user (currently display_name).
|
||||
// PUT /api/me
|
||||
func (h *MeHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := userIDFromCtx(r)
|
||||
if !ok {
|
||||
renderError(w, http.StatusUnauthorized, "Unauthorized", "Missing user context.")
|
||||
return
|
||||
}
|
||||
var req updateMeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only update when display_name is provided in the body (pointer non-nil).
|
||||
// Trimming to keep stored names tidy; an empty string clears the field.
|
||||
if req.DisplayName == nil {
|
||||
// Nothing to change – return the current profile.
|
||||
u, err := h.users.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.")
|
||||
return
|
||||
}
|
||||
renderJSON(w, http.StatusOK, toUserDTO(u))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(*req.DisplayName)
|
||||
u, err := h.users.UpdateDisplayName(r.Context(), userID, name)
|
||||
if err != nil {
|
||||
slog.Error("update me failed", "error", err, "user_id", userID)
|
||||
renderError(w, http.StatusInternalServerError, "Internal error", "Could not update user.")
|
||||
return
|
||||
}
|
||||
renderJSON(w, http.StatusOK, toUserDTO(u))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue