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 594d7b4..ac01186 100644 --- a/android/app/src/main/java/com/example/mitbringsl/Navigation.kt +++ b/android/app/src/main/java/com/example/mitbringsl/Navigation.kt @@ -1,9 +1,6 @@ package com.example.mitbringsl -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay @@ -14,34 +11,30 @@ import com.example.mitbringsl.ui.lists.ListsScreen @Composable fun MainNavigation(sessionManager: SessionManager) { - val initialKey = remember { - if (!sessionManager.getToken().isNullOrBlank()) ListsNavKey else AuthNavKey - } - val backStack = rememberNavBackStack(initialKey) + // Local-First: App starts directly at ListsNavKey by default! + val backStack = rememberNavBackStack(ListsNavKey) NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = entryProvider { - entry { - AuthScreen( - onLoginSuccess = { - backStack.clear() - backStack.add(ListsNavKey) - } - ) - } entry { ListsScreen( onSelectList = { listId -> backStack.add(ListDetailNavKey(listId)) }, - onLogout = { - backStack.clear() + onOpenSync = { backStack.add(AuthNavKey) } ) } + entry { + AuthScreen( + onLoginSuccess = { + backStack.removeLastOrNull() + } + ) + } entry { key -> ListDetailScreen( onBack = { backStack.removeLastOrNull() } 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 22abeb0..a616d36 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 @@ -6,11 +6,16 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton /** - * Manages the user's session state and token storage. + * Manages the user's session state and local device identification. + * + * Local-First design: Users do NOT need an account to use the app locally. + * When not logged in, [getUserId] returns a stable local ID ("local_user"). + * Logging in is 100% optional and only required to sync across devices. */ @Singleton class SessionManager @Inject constructor( @@ -18,12 +23,29 @@ class SessionManager @Inject constructor( ) { private val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) + companion object { + const val LOCAL_USER_ID = "local_user" + } + private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank()) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() fun getToken(): String? = prefs.getString("token", null) - fun getUserId(): String? = prefs.getString("user_id", null) + /** + * Returns the authenticated user's ID if logged in, or [LOCAL_USER_ID] for local-only mode. + */ + fun getUserId(): String { + val remoteId = prefs.getString("user_id", null) + if (!remoteId.isNullOrBlank()) return remoteId + + var localId = prefs.getString("local_device_user_id", null) + if (localId.isNullOrBlank()) { + localId = LOCAL_USER_ID + prefs.edit { putString("local_device_user_id", localId) } + } + return localId + } fun getUserEmail(): String? = prefs.getString("user_email", null) @@ -37,7 +59,11 @@ class SessionManager @Inject constructor( } fun clearSession() { - prefs.edit { clear() } + prefs.edit { + remove("token") + remove("user_id") + remove("user_email") + } _isLoggedIn.value = false } } diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt index 3023e06..8116bc7 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -37,11 +36,7 @@ fun AuthScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() - - if (isLoggedIn) { - onLoginSuccess() - return - } + val userEmail = viewModel.sessionManager.getUserEmail() val focusManager = LocalFocusManager.current val gradientBrush = Brush.verticalGradient( @@ -74,24 +69,21 @@ fun AuthScreen( shadowElevation = 8.dp ) { Box(contentAlignment = Alignment.Center) { - Text( - text = "🛒", - fontSize = 36.sp - ) + Text(text = "🛒", fontSize = 36.sp) } } Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Mitbringsl", + text = "Mitbringsl Sync", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Local-First Einkaufsliste — Werbefrei & Sicher", + text = "Melde dich an, um deine Listen geräteübergreifend zu synchronisieren.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center @@ -112,132 +104,170 @@ fun AuthScreen( modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = if (uiState.isRegisteringMode) "Konto erstellen" else "Willkommen zurück", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) + if (isLoggedIn) { + Text( + text = "Angemeldet als", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = userEmail ?: "Benutzer", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = { + viewModel.sessionManager.clearSession() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Abmelden") + } + } else { + Text( + text = if (uiState.isRegisteringMode) "Konto erstellen" else "Anmelden", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) - // Error Message - AnimatedVisibility( - visible = uiState.errorMessage != null, - enter = fadeIn(), - exit = fadeOut() - ) { - uiState.errorMessage?.let { msg -> - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - Text( - text = msg, - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - textAlign = TextAlign.Center - ) + Spacer(modifier = Modifier.height(20.dp)) + + // Error Message + AnimatedVisibility( + visible = uiState.errorMessage != null, + enter = fadeIn(), + exit = fadeOut() + ) { + uiState.errorMessage?.let { msg -> + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Text( + text = msg, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + textAlign = TextAlign.Center + ) + } } } - } - // Display Name (only in register mode) - if (uiState.isRegisteringMode) { + // Display Name (only in register mode) + if (uiState.isRegisteringMode) { + OutlinedTextField( + value = uiState.displayNameInput, + onValueChange = viewModel::onDisplayNameChanged, + label = { Text("Anzeigename (optional)") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + // Email Field OutlinedTextField( - value = uiState.displayNameInput, - onValueChange = viewModel::onDisplayNameChanged, - label = { Text("Anzeigename (optional)") }, + value = uiState.emailInput, + onValueChange = viewModel::onEmailChanged, + label = { Text("E-Mail Adresse") }, singleLine = true, shape = RoundedCornerShape(16.dp), modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) ) + Spacer(modifier = Modifier.height(12.dp)) - } - // Email Field - OutlinedTextField( - value = uiState.emailInput, - onValueChange = viewModel::onEmailChanged, - label = { Text("E-Mail Adresse") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next + // Password Field + OutlinedTextField( + value = uiState.passwordInput, + onValueChange = viewModel::onPasswordChanged, + label = { Text("Passwort") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + viewModel.submit() + } + ) ) - ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // Password Field - OutlinedTextField( - value = uiState.passwordInput, - onValueChange = viewModel::onPasswordChanged, - label = { Text("Passwort") }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { + // Submit Button + Button( + onClick = { focusManager.clearFocus() viewModel.submit() + }, + enabled = !uiState.isLoading, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.5.dp + ) + } else { + Text( + text = if (uiState.isRegisteringMode) "Registrieren" else "Anmelden", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) } - ) - ) + } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(12.dp)) - // Submit Button - Button( - onClick = { - focusManager.clearFocus() - viewModel.submit() - }, - enabled = !uiState.isLoading, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.5.dp - ) - } else { + // Toggle Login / Register mode + TextButton(onClick = viewModel::toggleMode) { Text( - text = if (uiState.isRegisteringMode) "Registrieren" else "Anmelden", - fontWeight = FontWeight.Bold, - fontSize = 16.sp + text = if (uiState.isRegisteringMode) { + "Bereits ein Konto? Hier anmelden" + } else { + "Noch kein Konto? Hier registrieren" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium ) } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) - // Toggle Login / Register mode - TextButton(onClick = viewModel::toggleMode) { + TextButton(onClick = onLoginSuccess) { Text( - text = if (uiState.isRegisteringMode) { - "Bereits ein Konto? Hier anmelden" - } else { - "Noch kein Konto? Hier registrieren" - }, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyMedium + text = "Zurück zu meinen Listen", + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } 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 e28848c..e6fe613 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 @@ -3,7 +3,6 @@ package com.example.mitbringsl.ui.lists import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -11,8 +10,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +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.ExitToApp +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material3.* @@ -31,12 +32,15 @@ import com.example.mitbringsl.data.local.entity.ListEntity @Composable fun ListsScreen( onSelectList: (String) -> Unit, - onLogout: () -> Unit, + onOpenSync: () -> Unit, modifier: Modifier = Modifier, viewModel: ListsViewModel = hiltViewModel(), ) { val lists by viewModel.lists.collectAsStateWithLifecycle() val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() + val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() + val userEmail = viewModel.sessionManager.getUserEmail() + var showAddDialog by remember { mutableStateOf(false) } Scaffold( @@ -49,15 +53,26 @@ fun ListsScreen( ) }, actions = { - IconButton(onClick = { - viewModel.logout() - onLogout() - }) { - Icon( - imageVector = Icons.Default.ExitToApp, - contentDescription = "Abmelden" - ) - } + // Sync / Account button + AssistChip( + onClick = onOpenSync, + label = { + Text( + text = if (isLoggedIn) (userEmail?.substringBefore("@") ?: "Sync aktiv") else "Sync / Account", + style = MaterialTheme.typography.labelSmall + ) + }, + leadingIcon = { + Icon( + imageVector = if (isLoggedIn) Icons.Default.CloudDone else Icons.Default.CloudOff, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ) + }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.padding(end = 8.dp) + ) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface @@ -106,7 +121,7 @@ fun ListsScreen( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Offline – Änderungen werden lokal gespeichert", + text = "Offline – Änderungen bleiben lokal gespeichert", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onErrorContainer ) @@ -140,7 +155,7 @@ fun ListsScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Erstelle deine erste Liste mit dem Plus-Button unten rechts.", + text = "Erstelle direkt deine erste Liste — ohne Anmeldung nutzbar!", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center