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()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MitbringslTheme {
|
MitbringslTheme(sessionManager = sessionManager) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
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.auth.AuthScreen
|
||||||
import com.example.mitbringsl.ui.detail.ListDetailScreen
|
import com.example.mitbringsl.ui.detail.ListDetailScreen
|
||||||
import com.example.mitbringsl.ui.lists.ListsScreen
|
import com.example.mitbringsl.ui.lists.ListsScreen
|
||||||
|
import com.example.mitbringsl.ui.settings.SettingsScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainNavigation(sessionManager: SessionManager) {
|
fun MainNavigation(sessionManager: SessionManager) {
|
||||||
|
|
@ -25,6 +26,9 @@ fun MainNavigation(sessionManager: SessionManager) {
|
||||||
},
|
},
|
||||||
onOpenSync = {
|
onOpenSync = {
|
||||||
backStack.add(AuthNavKey)
|
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 ->
|
entry<ListDetailNavKey> { key ->
|
||||||
ListDetailScreen(
|
ListDetailScreen(
|
||||||
listId = key.listId,
|
listId = key.listId,
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable data object AuthNavKey : NavKey
|
@Serializable data object AuthNavKey : NavKey
|
||||||
@Serializable data object ListsNavKey : NavKey
|
@Serializable data object ListsNavKey : NavKey
|
||||||
|
@Serializable data object SettingsNavKey : NavKey
|
||||||
@Serializable data class ListDetailNavKey(val listId: String) : NavKey
|
@Serializable data class ListDetailNavKey(val listId: String) : NavKey
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,17 @@ class SessionManager @Inject constructor(
|
||||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
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 {
|
fun getServerUrl(): String {
|
||||||
return prefs.getString("server_url", null) ?: BuildConfig.BASE_URL
|
return prefs.getString("server_url", null) ?: BuildConfig.BASE_URL
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ interface MitbringslApi {
|
||||||
@POST("auth/logout")
|
@POST("auth/logout")
|
||||||
suspend fun logout(): Response<Unit>
|
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 --------------------------------------------------------------
|
// --- Lists --------------------------------------------------------------
|
||||||
|
|
||||||
@GET("api/lists")
|
@GET("api/lists")
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ data class UserDto(
|
||||||
@SerialName("display_name") val displayName: String = "",
|
@SerialName("display_name") val displayName: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateMeRequestDto(
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Lists
|
// Lists
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80)
|
private val DarkColorScheme = darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80)
|
||||||
|
|
||||||
|
|
@ -17,25 +20,27 @@ private val LightColorScheme =
|
||||||
primary = Purple40,
|
primary = Purple40,
|
||||||
secondary = PurpleGrey40,
|
secondary = PurpleGrey40,
|
||||||
tertiary = Pink40,
|
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
|
@Composable
|
||||||
fun MitbringslTheme(
|
fun MitbringslTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
sessionManager: SessionManager,
|
||||||
// Dynamic color is available on Android 12+
|
// Dynamic color is available on Android 12+
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
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 =
|
val colorScheme =
|
||||||
when {
|
when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
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.CloudOff
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.GroupAdd
|
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.ShoppingCart
|
||||||
import androidx.compose.material.icons.filled.WifiOff
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
|
@ -33,6 +34,7 @@ import com.example.mitbringsl.data.local.entity.ListEntity
|
||||||
fun ListsScreen(
|
fun ListsScreen(
|
||||||
onSelectList: (String) -> Unit,
|
onSelectList: (String) -> Unit,
|
||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -56,6 +58,12 @@ fun ListsScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = "Einstellungen"
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
showJoinDialog = true
|
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
|
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
|
// 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
|
// (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
|
// empty if the IdP did not provide one; we store a synthesized placeholder so
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type API struct {
|
||||||
ops *OpsHandler
|
ops *OpsHandler
|
||||||
suggest *SuggestHandler
|
suggest *SuggestHandler
|
||||||
config *ConfigHandler
|
config *ConfigHandler
|
||||||
|
me *MeHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI constructs the API with all handler groups.
|
// 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),
|
ops: NewOpsHandler(opStore, listStore),
|
||||||
suggest: NewSuggestHandler(suggestStore),
|
suggest: NewSuggestHandler(suggestStore),
|
||||||
config: NewConfigHandler(cfg),
|
config: NewConfigHandler(cfg),
|
||||||
|
me: NewMeHandler(users),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +87,8 @@ func (a *API) Handler() http.Handler {
|
||||||
mux.HandleFunc("POST /auth/logout", a.auth.Logout)
|
mux.HandleFunc("POST /auth/logout", a.auth.Logout)
|
||||||
|
|
||||||
// --- authenticated API endpoints ---
|
// --- 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("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", a.RequireAuth(http.HandlerFunc(a.lists.Create)))
|
||||||
mux.Handle("POST /api/lists/join", a.RequireAuth(http.HandlerFunc(a.lists.Join)))
|
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