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:
Tronax 2026-08-05 20:19:29 +02:00
parent a00db14cba
commit a5ad5cd766
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
4 changed files with 209 additions and 145 deletions

View file

@ -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() }

View file

@ -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
}
}

View file

@ -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
)
}
}

View file

@ -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