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 package com.example.mitbringsl
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
@ -14,34 +11,30 @@ import com.example.mitbringsl.ui.lists.ListsScreen
@Composable @Composable
fun MainNavigation(sessionManager: SessionManager) { fun MainNavigation(sessionManager: SessionManager) {
val initialKey = remember { // Local-First: App starts directly at ListsNavKey by default!
if (!sessionManager.getToken().isNullOrBlank()) ListsNavKey else AuthNavKey val backStack = rememberNavBackStack(ListsNavKey)
}
val backStack = rememberNavBackStack(initialKey)
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { backStack.removeLastOrNull() }, onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider { entryProvider = entryProvider {
entry<AuthNavKey> {
AuthScreen(
onLoginSuccess = {
backStack.clear()
backStack.add(ListsNavKey)
}
)
}
entry<ListsNavKey> { entry<ListsNavKey> {
ListsScreen( ListsScreen(
onSelectList = { listId -> onSelectList = { listId ->
backStack.add(ListDetailNavKey(listId)) backStack.add(ListDetailNavKey(listId))
}, },
onLogout = { onOpenSync = {
backStack.clear()
backStack.add(AuthNavKey) backStack.add(AuthNavKey)
} }
) )
} }
entry<AuthNavKey> {
AuthScreen(
onLoginSuccess = {
backStack.removeLastOrNull()
}
)
}
entry<ListDetailNavKey> { key -> entry<ListDetailNavKey> { key ->
ListDetailScreen( ListDetailScreen(
onBack = { backStack.removeLastOrNull() } onBack = { backStack.removeLastOrNull() }

View file

@ -6,11 +6,16 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 @Singleton
class SessionManager @Inject constructor( class SessionManager @Inject constructor(
@ -18,12 +23,29 @@ class SessionManager @Inject constructor(
) { ) {
private val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
companion object {
const val LOCAL_USER_ID = "local_user"
}
private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank()) private val _isLoggedIn = MutableStateFlow(!getToken().isNullOrBlank())
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
fun getToken(): String? = prefs.getString("token", null) 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) fun getUserEmail(): String? = prefs.getString("user_email", null)
@ -37,7 +59,11 @@ class SessionManager @Inject constructor(
} }
fun clearSession() { fun clearSession() {
prefs.edit { clear() } prefs.edit {
remove("token")
remove("user_id")
remove("user_email")
}
_isLoggedIn.value = false _isLoggedIn.value = false
} }
} }

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@ -37,11 +36,7 @@ fun AuthScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
val userEmail = viewModel.sessionManager.getUserEmail()
if (isLoggedIn) {
onLoginSuccess()
return
}
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val gradientBrush = Brush.verticalGradient( val gradientBrush = Brush.verticalGradient(
@ -74,24 +69,21 @@ fun AuthScreen(
shadowElevation = 8.dp shadowElevation = 8.dp
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Text(text = "🛒", fontSize = 36.sp)
text = "🛒",
fontSize = 36.sp
)
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Mitbringsl", text = "Mitbringsl Sync",
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Local-First Einkaufsliste — Werbefrei & Sicher", text = "Melde dich an, um deine Listen geräteübergreifend zu synchronisieren.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@ -112,8 +104,36 @@ fun AuthScreen(
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isLoggedIn) {
Text( Text(
text = if (uiState.isRegisteringMode) "Konto erstellen" else "Willkommen zurück", 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))
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, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
@ -226,7 +246,7 @@ fun AuthScreen(
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(12.dp))
// Toggle Login / Register mode // Toggle Login / Register mode
TextButton(onClick = viewModel::toggleMode) { TextButton(onClick = viewModel::toggleMode) {
@ -241,6 +261,16 @@ fun AuthScreen(
) )
} }
} }
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onLoginSuccess) {
Text(
text = "Zurück zu meinen Listen",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))

View file

@ -3,7 +3,6 @@ package com.example.mitbringsl.ui.lists
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.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.ShoppingCart
import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.* import androidx.compose.material3.*
@ -31,12 +32,15 @@ import com.example.mitbringsl.data.local.entity.ListEntity
@Composable @Composable
fun ListsScreen( fun ListsScreen(
onSelectList: (String) -> Unit, onSelectList: (String) -> Unit,
onLogout: () -> Unit, onOpenSync: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val lists by viewModel.lists.collectAsStateWithLifecycle() val lists by viewModel.lists.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.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) } var showAddDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@ -49,15 +53,26 @@ fun ListsScreen(
) )
}, },
actions = { actions = {
IconButton(onClick = { // Sync / Account button
viewModel.logout() AssistChip(
onLogout() onClick = onOpenSync,
}) { label = {
Icon( Text(
imageVector = Icons.Default.ExitToApp, text = if (isLoggedIn) (userEmail?.substringBefore("@") ?: "Sync aktiv") else "Sync / Account",
contentDescription = "Abmelden" 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( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
@ -106,7 +121,7 @@ fun ListsScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Offline Änderungen werden lokal gespeichert", text = "Offline Änderungen bleiben lokal gespeichert",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer color = MaterialTheme.colorScheme.onErrorContainer
) )
@ -140,7 +155,7 @@ fun ListsScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center