Refactor: Make account 100% optional, default to local-only mode
- SessionManager: return stable local_user ID when not logged in
- Navigation: start directly at ListsNavKey without requiring login
- ListsScreen: add 'Sync / Account' chip in top bar; lists work 100% locally by default
- AuthScreen: update text & add 'Zurück zu meinen Listen' button for returning to local mode
- ADB reinstall & launch verified ✅
This commit is contained in:
parent
a00db14cba
commit
a5ad5cd766
4 changed files with 209 additions and 145 deletions
|
|
@ -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<AuthNavKey> {
|
||||
AuthScreen(
|
||||
onLoginSuccess = {
|
||||
backStack.clear()
|
||||
backStack.add(ListsNavKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<ListsNavKey> {
|
||||
ListsScreen(
|
||||
onSelectList = { listId ->
|
||||
backStack.add(ListDetailNavKey(listId))
|
||||
},
|
||||
onLogout = {
|
||||
backStack.clear()
|
||||
onOpenSync = {
|
||||
backStack.add(AuthNavKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<AuthNavKey> {
|
||||
AuthScreen(
|
||||
onLoginSuccess = {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<ListDetailNavKey> { key ->
|
||||
ListDetailScreen(
|
||||
onBack = { backStack.removeLastOrNull() }
|
||||
|
|
|
|||
|
|
@ -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<Boolean> = _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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue