diff --git a/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt b/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt index aa54481..0614ba3 100644 --- a/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt +++ b/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt @@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - MitbringslTheme { + MitbringslTheme(sessionManager = sessionManager) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background diff --git a/android/app/src/main/java/com/example/mitbringsl/Navigation.kt b/android/app/src/main/java/com/example/mitbringsl/Navigation.kt index db527b9..ef6b0bd 100644 --- a/android/app/src/main/java/com/example/mitbringsl/Navigation.kt +++ b/android/app/src/main/java/com/example/mitbringsl/Navigation.kt @@ -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 { + SettingsScreen( + onBack = { backStack.removeLastOrNull() }, + onOpenSync = { + backStack.add(AuthNavKey) + } + ) + } entry { key -> ListDetailScreen( listId = key.listId, diff --git a/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt b/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt index e0eaf5a..1aecd44 100644 --- a/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt +++ b/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt @@ -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 diff --git a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt index b61e6ed..5a75e2f 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt @@ -29,6 +29,17 @@ class SessionManager @Inject constructor( private val _serverUrl = MutableStateFlow(getServerUrl()) val serverUrl: StateFlow = _serverUrl.asStateFlow() + // Theme preference: "system" | "light" | "dark" + private val _themeMode = MutableStateFlow(getThemeMode()) + val themeMode: StateFlow = _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 } diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt index 4cd3929..063de39 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/api/MitbringslApi.kt @@ -27,6 +27,14 @@ interface MitbringslApi { @POST("auth/logout") suspend fun logout(): Response + // --- Profile (/api/me) -------------------------------------------------- + + @GET("api/me") + suspend fun getMe(): Response + + @PUT("api/me") + suspend fun updateMe(@Body body: UpdateMeRequestDto): Response + // --- Lists -------------------------------------------------------------- @GET("api/lists") diff --git a/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt index da2d62a..49f89f9 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/remote/dto/Dtos.kt @@ -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 // --------------------------------------------------------------------------- diff --git a/android/app/src/main/java/com/example/mitbringsl/theme/Theme.kt b/android/app/src/main/java/com/example/mitbringsl/theme/Theme.kt index c1a127d..195898d 100644 --- a/android/app/src/main/java/com/example/mitbringsl/theme/Theme.kt +++ b/android/app/src/main/java/com/example/mitbringsl/theme/Theme.kt @@ -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 -> { diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt index 8fd06aa..49d48de 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..add02a9 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/settings/SettingsScreen.kt @@ -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) + ) + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..303c391 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/settings/SettingsViewModel.kt @@ -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 = _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) } + } +} diff --git a/backend/internal/auth/user.go b/backend/internal/auth/user.go index c6139ce..119e1e6 100644 --- a/backend/internal/auth/user.go +++ b/backend/internal/auth/user.go @@ -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 diff --git a/backend/internal/httpapi/api.go b/backend/internal/httpapi/api.go index a2e06f8..00dbf82 100644 --- a/backend/internal/httpapi/api.go +++ b/backend/internal/httpapi/api.go @@ -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))) diff --git a/backend/internal/httpapi/me.go b/backend/internal/httpapi/me.go new file mode 100644 index 0000000..d2f13cc --- /dev/null +++ b/backend/internal/httpapi/me.go @@ -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)) +}