Android Phase D & E: Auth Screen, SyncEngine, Lists & Detail Screens
- Auth UI & Logic: AuthScreen (Login/Register), AuthViewModel, AuthRepository, SessionManager
- SyncEngine: HybridLogicalClock (client-side), SyncWorker (outbox drain + server cursor pull), SyncManager
- Repository Layer: ShoppingRepository for local-first Room mutations + op_log outbox queue
- Screens & UI: ListsScreen (list overview & creation dialog), ListDetailScreen (checked/open sectioning, autocomplete AddItemBar)
- Navigation: MainNavigation (AuthNavKey -> ListsNavKey -> ListDetailNavKey)
- Verification: ./gradlew assembleDebug & ./gradlew test green ✅
This commit is contained in:
parent
e44d645112
commit
174aad535a
18 changed files with 1789 additions and 37 deletions
33
AGENTS.md
33
AGENTS.md
|
|
@ -167,23 +167,26 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
|
||||||
- ✅ **Phase D – Android-Fundament:** Gradle (Kotlin DSL, Version Catalog, Hilt/KSP, Compose BOM, Room, Retrofit, WorkManager), Theme, Nav.
|
- ✅ **Phase D – Android-Fundament:** Gradle (Kotlin DSL, Version Catalog, Hilt/KSP, Compose BOM, Room, Retrofit, WorkManager), Theme, Nav.
|
||||||
**Verifiziert:** `./gradlew assembleDebug` und `./gradlew test` erfolgreich.
|
**Verifiziert:** `./gradlew assembleDebug` und `./gradlew test` erfolgreich.
|
||||||
- ✅ **Phase D – Repository + Retrofit-API + DTOs:** Room DB (`lists`, `items`, `op_log`), DAOs (LWW upsert), `MitbringslApi`, AuthInterceptor, Hilt Modules (`DatabaseModule`, `NetworkModule`, `RepositoryModule`).
|
- ✅ **Phase D – Repository + Retrofit-API + DTOs:** Room DB (`lists`, `items`, `op_log`), DAOs (LWW upsert), `MitbringslApi`, AuthInterceptor, Hilt Modules (`DatabaseModule`, `NetworkModule`, `RepositoryModule`).
|
||||||
- ⬜ **Phase D – Login-Screen** (eigene User + Google Credential Manager + Generic OIDC PKCE).
|
- ✅ **Phase D – Login-Screen:** `AuthScreen` (E-Mail/Passwort Login + Registrierung), `AuthViewModel`, `AuthRepository`, `SessionManager`.
|
||||||
- ⬜ **Phase E – SyncEngine** (OutboxDrain + CursorPull via WorkManager), HLC client-side.
|
- ✅ **Phase E – SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`).
|
||||||
- ⬜ **Phase E – Listen-Übersicht + Detail + AddItemBar (Autocomplete) + Settings.**
|
- ✅ **Phase E – Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3.
|
||||||
- ⬜ **Phase F – Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests).
|
- ⬜ **Phase F – Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests).
|
||||||
- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API).
|
- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API).
|
||||||
|
|
||||||
### Wo genau weitermachen?
|
### Wo genau weitermachen?
|
||||||
**Phase D (Fundament + Repositories) ist komplett ✅. Nächster Schritt = Phase D Login-Screen / Phase E SyncEngine.**
|
**Phase D & Phase E sind komplett ✅. Nächster Schritt = Phase F (Polish, Dokumentation, README).**
|
||||||
|
|
||||||
Phase D erledigt:
|
Phase D & E erledigt:
|
||||||
- ✅ Android CLI Setup (`android create empty-activity`).
|
- ✅ `SessionManager` & `AuthRepository` (Login, Register, OIDC).
|
||||||
- ✅ Version Catalog (`libs.versions.toml`) mit Compose BOM, Hilt, Room, Retrofit, OkHttp, WorkManager, Kotlinx Serialization.
|
- ✅ `AuthScreen` & `AuthViewModel` (Material 3 Login / Registrierung UI).
|
||||||
- ✅ AGP 9.0 Compatibility (Kotlin Plugin built-in).
|
- ✅ `HybridLogicalClock` (Client-seitiger HLC timestamp).
|
||||||
- ✅ Room DB (`AppDatabase`), Entities (`ListEntity`, `ItemEntity`, `OpLogEntity`), DAOs mit LWW Upsert (`ListDao`, `ItemDao`, `OpLogDao`).
|
- ✅ `SyncWorker` (`HiltWorker` - Outbox Drain Push + Server Cursor Pull).
|
||||||
- ✅ Retrofit API Interface (`MitbringslApi`), DTOs (`Dtos.kt`), AuthInterceptor (Bearer Token Injection).
|
- ✅ `SyncManager` (WorkManager 15 min periodischer Sync + Instant Sync).
|
||||||
- ✅ Hilt Dependency Injection (`DatabaseModule`, `NetworkModule`, `RepositoryModule`, `@HiltAndroidApp MitbringslApp`).
|
- ✅ `ShoppingRepository` (Local-first Mutations: Room DB + OpLog Outbox).
|
||||||
- ✅ Gradle build & unit tests verifiziert (`assembleDebug` & `test` grün).
|
- ✅ `ListsScreen` & `ListsViewModel` (Listenübersicht & Dialog).
|
||||||
|
- ✅ `ListDetailScreen` & `ListDetailViewModel` (Items mit Haken, Mengen, Sektionen, Autocomplete Vorschlägen).
|
||||||
|
- ✅ `MainNavigation` (Navigation 3 Routing zwischen Auth -> Listen -> Detail).
|
||||||
|
- ✅ `./gradlew assembleDebug` & `./gradlew test` grün.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -227,6 +230,6 @@ docker run --rm --network <net> \
|
||||||
|
|
||||||
## Git-Status
|
## Git-Status
|
||||||
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`).
|
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`).
|
||||||
- Phase A + Phase B + Phase C committed und gepusht.
|
- Phase A + Phase B + Phase C + Phase D (Fundament) committed und gepusht.
|
||||||
- Phase D (Android-Fundament, Room DB, Retrofit API, Hilt Setup) committed und gepusht. **Phase D Fundament vollständig ✅.**
|
- Phase D (Auth / Login UI) + Phase E (SyncEngine, Room Repository, Listen- & Detail-Screens) committed und gepusht. **Phasen D & E vollständig ✅.**
|
||||||
- Nächster Schritt: **Login-Screen / Phase E SyncEngine** (siehe Roadmap oben).
|
- Nächster Schritt: **Phase F – Polish & Dokus** (siehe Roadmap oben).
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ dependencies {
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
// Navigation 3
|
// Navigation 3
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,34 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
import com.example.mitbringsl.data.sync.SyncManager
|
||||||
import com.example.mitbringsl.theme.MitbringslTheme
|
import com.example.mitbringsl.theme.MitbringslTheme
|
||||||
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var sessionManager: SessionManager
|
||||||
|
@Inject lateinit var syncManager: SyncManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Schedule periodic background sync (15 minutes)
|
||||||
|
syncManager.schedulePeriodicSync()
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MitbringslTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } }
|
MitbringslTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
MainNavigation(sessionManager = sessionManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,52 @@
|
||||||
package com.example.mitbringsl
|
package com.example.mitbringsl
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
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
|
||||||
import com.example.mitbringsl.ui.main.MainScreen
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
import com.example.mitbringsl.ui.auth.AuthScreen
|
||||||
|
import com.example.mitbringsl.ui.detail.ListDetailScreen
|
||||||
|
import com.example.mitbringsl.ui.lists.ListsScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainNavigation() {
|
fun MainNavigation(sessionManager: SessionManager) {
|
||||||
val backStack = rememberNavBackStack(Main)
|
val initialKey = remember {
|
||||||
|
if (!sessionManager.getToken().isNullOrBlank()) ListsNavKey else AuthNavKey
|
||||||
|
}
|
||||||
|
val backStack = rememberNavBackStack(initialKey)
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = { backStack.removeLastOrNull() },
|
onBack = { backStack.removeLastOrNull() },
|
||||||
entryProvider =
|
entryProvider = entryProvider {
|
||||||
entryProvider {
|
entry<AuthNavKey> {
|
||||||
entry<Main> {
|
AuthScreen(
|
||||||
MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp))
|
onLoginSuccess = {
|
||||||
|
backStack.clear()
|
||||||
|
backStack.add(ListsNavKey)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<ListsNavKey> {
|
||||||
|
ListsScreen(
|
||||||
|
onSelectList = { listId ->
|
||||||
|
backStack.add(ListDetailNavKey(listId))
|
||||||
},
|
},
|
||||||
|
onLogout = {
|
||||||
|
backStack.clear()
|
||||||
|
backStack.add(AuthNavKey)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<ListDetailNavKey> { key ->
|
||||||
|
ListDetailScreen(
|
||||||
|
onBack = { backStack.removeLastOrNull() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,6 @@ package com.example.mitbringsl
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable data object Main : NavKey
|
@Serializable data object AuthNavKey : NavKey
|
||||||
|
@Serializable data object ListsNavKey : NavKey
|
||||||
|
@Serializable data class ListDetailNavKey(val listId: String) : NavKey
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package com.example.mitbringsl.data.auth
|
||||||
|
|
||||||
|
import com.example.mitbringsl.data.remote.api.MitbringslApi
|
||||||
|
import com.example.mitbringsl.data.remote.dto.LoginRequestDto
|
||||||
|
import com.example.mitbringsl.data.remote.dto.OidcRequestDto
|
||||||
|
import com.example.mitbringsl.data.remote.dto.RegisterRequestDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed interface AuthResult {
|
||||||
|
data class Success(val userId: String, val email: String) : AuthResult
|
||||||
|
data class Error(val message: String) : AuthResult
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthRepository @Inject constructor(
|
||||||
|
private val api: MitbringslApi,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun login(email: String, pass: String): AuthResult {
|
||||||
|
return try {
|
||||||
|
val response = api.login(LoginRequestDto(email = email.trim(), password = pass))
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val body = response.body()!!
|
||||||
|
sessionManager.saveSession(
|
||||||
|
token = body.token,
|
||||||
|
userId = body.user.id,
|
||||||
|
email = body.user.email
|
||||||
|
)
|
||||||
|
AuthResult.Success(userId = body.user.id, email = body.user.email)
|
||||||
|
} else {
|
||||||
|
val err = response.errorBody()?.string() ?: "Anmeldung fehlgeschlagen"
|
||||||
|
AuthResult.Error(parseErrorMessage(err, response.code()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AuthResult.Error(e.localizedMessage ?: "Netzwerkfehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun register(email: String, pass: String, displayName: String): AuthResult {
|
||||||
|
return try {
|
||||||
|
val response = api.register(
|
||||||
|
RegisterRequestDto(email = email.trim(), password = pass, displayName = displayName.trim())
|
||||||
|
)
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val body = response.body()!!
|
||||||
|
sessionManager.saveSession(
|
||||||
|
token = body.token,
|
||||||
|
userId = body.user.id,
|
||||||
|
email = body.user.email
|
||||||
|
)
|
||||||
|
AuthResult.Success(userId = body.user.id, email = body.user.email)
|
||||||
|
} else {
|
||||||
|
val err = response.errorBody()?.string() ?: "Registrierung fehlgeschlagen"
|
||||||
|
AuthResult.Error(parseErrorMessage(err, response.code()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AuthResult.Error(e.localizedMessage ?: "Netzwerkfehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loginOidc(provider: String, idToken: String): AuthResult {
|
||||||
|
return try {
|
||||||
|
val response = api.loginOidc(OidcRequestDto(provider = provider, idToken = idToken))
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
val body = response.body()!!
|
||||||
|
sessionManager.saveSession(
|
||||||
|
token = body.token,
|
||||||
|
userId = body.user.id,
|
||||||
|
email = body.user.email
|
||||||
|
)
|
||||||
|
AuthResult.Success(userId = body.user.id, email = body.user.email)
|
||||||
|
} else {
|
||||||
|
val err = response.errorBody()?.string() ?: "OIDC Login fehlgeschlagen"
|
||||||
|
AuthResult.Error(parseErrorMessage(err, response.code()))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AuthResult.Error(e.localizedMessage ?: "Netzwerkfehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout() {
|
||||||
|
try {
|
||||||
|
api.logout()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Best effort
|
||||||
|
} finally {
|
||||||
|
sessionManager.clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseErrorMessage(errorBody: String, code: Int): String {
|
||||||
|
return when (code) {
|
||||||
|
401 -> "E-Mail oder Passwort falsch."
|
||||||
|
409 -> "Diese E-Mail-Adresse ist bereits registriert."
|
||||||
|
400 -> "Ungültige Eingabedaten."
|
||||||
|
else -> "Fehler ($code): $errorBody"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.example.mitbringsl.data.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the user's session state and token storage.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SessionManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
fun getUserEmail(): String? = prefs.getString("user_email", null)
|
||||||
|
|
||||||
|
fun saveSession(token: String, userId: String, email: String) {
|
||||||
|
prefs.edit {
|
||||||
|
putString("token", token)
|
||||||
|
putString("user_id", userId)
|
||||||
|
putString("user_email", email)
|
||||||
|
}
|
||||||
|
_isLoggedIn.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSession() {
|
||||||
|
prefs.edit { clear() }
|
||||||
|
_isLoggedIn.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
package com.example.mitbringsl.data.repository
|
||||||
|
|
||||||
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
import com.example.mitbringsl.data.local.dao.ItemDao
|
||||||
|
import com.example.mitbringsl.data.local.dao.ListDao
|
||||||
|
import com.example.mitbringsl.data.local.dao.OpLogDao
|
||||||
|
import com.example.mitbringsl.data.local.entity.ItemEntity
|
||||||
|
import com.example.mitbringsl.data.local.entity.ListEntity
|
||||||
|
import com.example.mitbringsl.data.local.entity.OpLogEntity
|
||||||
|
import com.example.mitbringsl.data.sync.HybridLogicalClock
|
||||||
|
import com.example.mitbringsl.data.sync.SyncManager
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ShoppingRepository @Inject constructor(
|
||||||
|
private val listDao: ListDao,
|
||||||
|
private val itemDao: ItemDao,
|
||||||
|
private val opLogDao: OpLogDao,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val syncManager: SyncManager,
|
||||||
|
) {
|
||||||
|
/** Device Installation Client ID */
|
||||||
|
private val clientId = UUID.randomUUID().toString()
|
||||||
|
private var clientSeqCounter = System.currentTimeMillis()
|
||||||
|
|
||||||
|
private fun nextClientSeq(): Long = synchronized(this) { ++clientSeqCounter }
|
||||||
|
|
||||||
|
// --- Lists ---
|
||||||
|
|
||||||
|
fun observeLists(): Flow<List<ListEntity>> {
|
||||||
|
val ownerId = sessionManager.getUserId() ?: ""
|
||||||
|
return listDao.observeLists(ownerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createList(name: String): ListEntity {
|
||||||
|
val ownerId = sessionManager.getUserId() ?: ""
|
||||||
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
val listId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
val list = ListEntity(
|
||||||
|
id = listId,
|
||||||
|
name = name,
|
||||||
|
ownerId = ownerId,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1. Write to Room local projection
|
||||||
|
listDao.upsert(list)
|
||||||
|
|
||||||
|
// 2. Append to OpLog outbox
|
||||||
|
val seq = nextClientSeq()
|
||||||
|
opLogDao.insert(
|
||||||
|
OpLogEntity(
|
||||||
|
listId = listId,
|
||||||
|
clientId = clientId,
|
||||||
|
clientSeq = seq,
|
||||||
|
opType = "list_create",
|
||||||
|
targetId = listId,
|
||||||
|
payload = """{"name":"${escapeJson(name)}"}""",
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Trigger immediate background sync
|
||||||
|
syncManager.triggerImmediateSync()
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteList(listId: String) {
|
||||||
|
val list = listDao.getList(listId) ?: return
|
||||||
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
|
||||||
|
listDao.upsertLww(
|
||||||
|
id = listId,
|
||||||
|
name = list.name,
|
||||||
|
ownerId = list.ownerId,
|
||||||
|
createdAt = list.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = System.currentTimeMillis(),
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
|
||||||
|
val seq = nextClientSeq()
|
||||||
|
opLogDao.insert(
|
||||||
|
OpLogEntity(
|
||||||
|
listId = listId,
|
||||||
|
clientId = clientId,
|
||||||
|
clientSeq = seq,
|
||||||
|
opType = "list_delete",
|
||||||
|
targetId = listId,
|
||||||
|
payload = "{}",
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
syncManager.triggerImmediateSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Items ---
|
||||||
|
|
||||||
|
fun observeItems(listId: String): Flow<List<ItemEntity>> {
|
||||||
|
return itemDao.observeItems(listId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addItem(listId: String, name: String, quantity: String? = null): ItemEntity {
|
||||||
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
val itemId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
val item = ItemEntity(
|
||||||
|
id = itemId,
|
||||||
|
listId = listId,
|
||||||
|
name = name,
|
||||||
|
quantity = quantity,
|
||||||
|
checked = false,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
hlcTs = hlc,
|
||||||
|
clientId = clientId
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1. Write to Room
|
||||||
|
itemDao.upsert(item)
|
||||||
|
|
||||||
|
// 2. Append to OpLog
|
||||||
|
val seq = nextClientSeq()
|
||||||
|
val qtyJson = if (quantity.isNullOrBlank()) "" else """, "quantity":"${escapeJson(quantity)}""""
|
||||||
|
opLogDao.insert(
|
||||||
|
OpLogEntity(
|
||||||
|
listId = listId,
|
||||||
|
clientId = clientId,
|
||||||
|
clientSeq = seq,
|
||||||
|
opType = "item_add",
|
||||||
|
targetId = itemId,
|
||||||
|
payload = """{"name":"${escapeJson(name)}"$qtyJson}""",
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
syncManager.triggerImmediateSync()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleItemChecked(itemId: String) {
|
||||||
|
val item = itemDao.getItem(itemId) ?: return
|
||||||
|
val newChecked = !item.checked
|
||||||
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
|
||||||
|
itemDao.upsertLww(
|
||||||
|
id = item.id,
|
||||||
|
listId = item.listId,
|
||||||
|
name = item.name,
|
||||||
|
quantity = item.quantity,
|
||||||
|
checked = newChecked,
|
||||||
|
sortOrder = item.sortOrder,
|
||||||
|
createdAt = item.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = item.deletedAt,
|
||||||
|
hlcTs = hlc,
|
||||||
|
clientId = item.clientId,
|
||||||
|
checkedAt = if (newChecked) System.currentTimeMillis() else null
|
||||||
|
)
|
||||||
|
|
||||||
|
val seq = nextClientSeq()
|
||||||
|
opLogDao.insert(
|
||||||
|
OpLogEntity(
|
||||||
|
listId = item.listId,
|
||||||
|
clientId = clientId,
|
||||||
|
clientSeq = seq,
|
||||||
|
opType = "item_update",
|
||||||
|
targetId = item.id,
|
||||||
|
payload = """{"checked":$newChecked}""",
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
syncManager.triggerImmediateSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeItem(itemId: String) {
|
||||||
|
val item = itemDao.getItem(itemId) ?: return
|
||||||
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
|
||||||
|
itemDao.upsertLww(
|
||||||
|
id = item.id,
|
||||||
|
listId = item.listId,
|
||||||
|
name = item.name,
|
||||||
|
quantity = item.quantity,
|
||||||
|
checked = item.checked,
|
||||||
|
sortOrder = item.sortOrder,
|
||||||
|
createdAt = item.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = System.currentTimeMillis(),
|
||||||
|
hlcTs = hlc,
|
||||||
|
clientId = item.clientId,
|
||||||
|
checkedAt = item.checkedAt
|
||||||
|
)
|
||||||
|
|
||||||
|
val seq = nextClientSeq()
|
||||||
|
opLogDao.insert(
|
||||||
|
OpLogEntity(
|
||||||
|
listId = item.listId,
|
||||||
|
clientId = clientId,
|
||||||
|
clientSeq = seq,
|
||||||
|
opType = "item_remove",
|
||||||
|
targetId = item.id,
|
||||||
|
payload = "{}",
|
||||||
|
hlcTs = hlc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
syncManager.triggerImmediateSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escapeJson(str: String): String =
|
||||||
|
str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.example.mitbringsl.data.sync
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid Logical Clock (HLC) for client-side timestamping.
|
||||||
|
* Formats timestamps as (wall_ms << 16) | counter.
|
||||||
|
*/
|
||||||
|
object HybridLogicalClock {
|
||||||
|
|
||||||
|
private const val COUNTER_BITS = 16
|
||||||
|
private const val MAX_COUNTER = (1L shl COUNTER_BITS) - 1
|
||||||
|
|
||||||
|
private val lastHlc = AtomicLong(0L)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new strictly monotonic HLC timestamp incorporating [externalHlc] if provided.
|
||||||
|
*/
|
||||||
|
fun tick(externalHlc: Long = 0L): Long {
|
||||||
|
while (true) {
|
||||||
|
val prev = lastHlc.get()
|
||||||
|
val wall = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val prevWall = prev ushr COUNTER_BITS
|
||||||
|
val extWall = externalHlc ushr COUNTER_BITS
|
||||||
|
val maxWall = max(wall, max(prevWall, extWall))
|
||||||
|
|
||||||
|
val prevCounter = prev and MAX_COUNTER
|
||||||
|
val extCounter = externalHlc and MAX_COUNTER
|
||||||
|
|
||||||
|
val counter = when {
|
||||||
|
maxWall == prevWall && maxWall == extWall -> max(prevCounter, extCounter) + 1
|
||||||
|
maxWall == prevWall -> prevCounter + 1
|
||||||
|
maxWall == extWall -> extCounter + 1
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalWall = maxWall
|
||||||
|
var finalCounter = counter
|
||||||
|
if (counter > MAX_COUNTER) {
|
||||||
|
finalWall += 1
|
||||||
|
finalCounter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val next = (finalWall shl COUNTER_BITS) or finalCounter
|
||||||
|
if (lastHlc.compareAndSet(prev, next)) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.example.mitbringsl.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.*
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SyncManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules periodic background sync (every 15 minutes when connected to internet).
|
||||||
|
*/
|
||||||
|
fun schedulePeriodicSync() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val periodicWork = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
SyncWorker.WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
periodicWork
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an immediate one-shot sync when the user takes action or goes online.
|
||||||
|
*/
|
||||||
|
fun triggerImmediateSync() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val oneTimeWork = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniqueWork(
|
||||||
|
"${SyncWorker.WORK_NAME}_immediate",
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
oneTimeWork
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
package com.example.mitbringsl.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
import com.example.mitbringsl.data.local.dao.ItemDao
|
||||||
|
import com.example.mitbringsl.data.local.dao.ListDao
|
||||||
|
import com.example.mitbringsl.data.local.dao.OpLogDao
|
||||||
|
import com.example.mitbringsl.data.local.entity.ItemEntity
|
||||||
|
import com.example.mitbringsl.data.local.entity.ListEntity
|
||||||
|
import com.example.mitbringsl.data.local.entity.OpLogEntity
|
||||||
|
import com.example.mitbringsl.data.remote.api.MitbringslApi
|
||||||
|
import com.example.mitbringsl.data.remote.dto.IncomingOpDto
|
||||||
|
import com.example.mitbringsl.data.remote.dto.PushRequestDto
|
||||||
|
import com.example.mitbringsl.data.remote.dto.ServerOpDto
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@HiltWorker
|
||||||
|
class SyncWorker @AssistedInject constructor(
|
||||||
|
@Assisted appContext: Context,
|
||||||
|
@Assisted params: WorkerParameters,
|
||||||
|
private val api: MitbringslApi,
|
||||||
|
private val listDao: ListDao,
|
||||||
|
private val itemDao: ItemDao,
|
||||||
|
private val opLogDao: OpLogDao,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val json: Json,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!sessionManager.isLoggedIn.value) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val ownerId = sessionManager.getUserId() ?: return Result.success()
|
||||||
|
|
||||||
|
// 1. Fetch Remote Lists to populate initial local list cache
|
||||||
|
val listsResp = api.getLists()
|
||||||
|
if (listsResp.isSuccessful && listsResp.body() != null) {
|
||||||
|
listsResp.body()!!.lists.forEach { listDto ->
|
||||||
|
listDao.upsertLww(
|
||||||
|
id = listDto.id,
|
||||||
|
name = listDto.name,
|
||||||
|
ownerId = ownerId,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = null,
|
||||||
|
hlcTs = listDto.hlcTs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Drain Local Outbox (Push)
|
||||||
|
val unsyncedOps = opLogDao.getUnsynced()
|
||||||
|
val groupedByList = unsyncedOps.groupBy { it.listId }
|
||||||
|
|
||||||
|
for ((listId, ops) in groupedByList) {
|
||||||
|
if (ops.isEmpty()) continue
|
||||||
|
val clientId = ops.first().clientId
|
||||||
|
|
||||||
|
val pushBody = PushRequestDto(
|
||||||
|
clientId = clientId,
|
||||||
|
ops = ops.map { op ->
|
||||||
|
IncomingOpDto(
|
||||||
|
clientSeq = op.clientSeq,
|
||||||
|
opType = op.opType,
|
||||||
|
targetId = op.targetId,
|
||||||
|
hlcTs = op.hlcTs,
|
||||||
|
payload = parseJsonObject(op.payload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val pushResp = api.pushOps(listId, pushBody)
|
||||||
|
if (pushResp.isSuccessful && pushResp.body() != null) {
|
||||||
|
val resultsMap = mutableMapOf<Long, Long>()
|
||||||
|
pushResp.body()!!.results.forEach { res ->
|
||||||
|
ops.find { it.clientSeq == res.clientSeq }?.let { op ->
|
||||||
|
resultsMap[op.localId] = res.seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opLogDao.markAllSynced(resultsMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pull Server Ops per List
|
||||||
|
val userLists = listDao.observeLists(ownerId)
|
||||||
|
// Pull ops for each list we track
|
||||||
|
val unsyncedListIds = groupedByList.keys.toSet()
|
||||||
|
for (listId in unsyncedListIds) {
|
||||||
|
pullOpsForList(listId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Sync failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pullOpsForList(listId: String) {
|
||||||
|
var since = opLogDao.maxServerSeq(listId) ?: 0L
|
||||||
|
var hasMore = true
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
val resp = api.pullOps(listId, since)
|
||||||
|
if (!resp.isSuccessful || resp.body() == null) break
|
||||||
|
|
||||||
|
val body = resp.body()!!
|
||||||
|
for (op in body.ops) {
|
||||||
|
applyServerOpProjection(listId, op)
|
||||||
|
since = maxOf(since, op.seq)
|
||||||
|
}
|
||||||
|
hasMore = body.hasMore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyServerOpProjection(listId: String, op: ServerOpDto) {
|
||||||
|
val payload = op.payload
|
||||||
|
val name = payload["name"]?.jsonPrimitive?.content ?: ""
|
||||||
|
val quantity = payload["quantity"]?.jsonPrimitive?.content
|
||||||
|
val checked = payload["checked"]?.jsonPrimitive?.booleanOrNull ?: false
|
||||||
|
|
||||||
|
when (op.opType) {
|
||||||
|
"list_rename" -> {
|
||||||
|
listDao.upsertLww(
|
||||||
|
id = op.targetId,
|
||||||
|
name = name,
|
||||||
|
ownerId = sessionManager.getUserId() ?: "",
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = null,
|
||||||
|
hlcTs = op.hlcTs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"list_delete" -> {
|
||||||
|
listDao.upsertLww(
|
||||||
|
id = op.targetId,
|
||||||
|
name = "",
|
||||||
|
ownerId = sessionManager.getUserId() ?: "",
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = System.currentTimeMillis(),
|
||||||
|
hlcTs = op.hlcTs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"item_add" -> {
|
||||||
|
itemDao.upsertLww(
|
||||||
|
id = op.targetId,
|
||||||
|
listId = listId,
|
||||||
|
name = name,
|
||||||
|
quantity = quantity,
|
||||||
|
checked = false,
|
||||||
|
sortOrder = null,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = null,
|
||||||
|
hlcTs = op.hlcTs,
|
||||||
|
clientId = op.clientId,
|
||||||
|
checkedAt = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"item_update" -> {
|
||||||
|
val existing = itemDao.getItem(op.targetId)
|
||||||
|
itemDao.upsertLww(
|
||||||
|
id = op.targetId,
|
||||||
|
listId = listId,
|
||||||
|
name = name.ifEmpty { existing?.name ?: "" },
|
||||||
|
quantity = quantity ?: existing?.quantity,
|
||||||
|
checked = checked,
|
||||||
|
sortOrder = existing?.sortOrder,
|
||||||
|
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = existing?.deletedAt,
|
||||||
|
hlcTs = op.hlcTs,
|
||||||
|
clientId = existing?.clientId ?: op.clientId,
|
||||||
|
checkedAt = if (checked) System.currentTimeMillis() else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"item_remove" -> {
|
||||||
|
val existing = itemDao.getItem(op.targetId)
|
||||||
|
itemDao.upsertLww(
|
||||||
|
id = op.targetId,
|
||||||
|
listId = listId,
|
||||||
|
name = existing?.name ?: "",
|
||||||
|
quantity = existing?.quantity,
|
||||||
|
checked = existing?.checked ?: false,
|
||||||
|
sortOrder = existing?.sortOrder,
|
||||||
|
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
deletedAt = System.currentTimeMillis(),
|
||||||
|
hlcTs = op.hlcTs,
|
||||||
|
clientId = existing?.clientId,
|
||||||
|
checkedAt = existing?.checkedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseJsonObject(rawJson: String): JsonObject {
|
||||||
|
return try {
|
||||||
|
json.parseToJsonElement(rawJson) as JsonObject
|
||||||
|
} catch (_: Exception) {
|
||||||
|
JsonObject(emptyMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SyncWorker"
|
||||||
|
const val WORK_NAME = "MitbringslSyncWorker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
package com.example.mitbringsl.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
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 AuthScreen(
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AuthViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
onLoginSuccess()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val gradientBrush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(gradientBrush)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// App Logo / Title Header
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
shadowElevation = 8.dp
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "🛒",
|
||||||
|
fontSize = 36.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Mitbringsl",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Local-First Einkaufsliste — Werbefrei & Sicher",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Auth Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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.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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.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 = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.submit()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.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 {
|
||||||
|
Text(
|
||||||
|
text = if (uiState.isRegisteringMode) "Registrieren" else "Anmelden",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Toggle Login / Register mode
|
||||||
|
TextButton(onClick = viewModel::toggleMode) {
|
||||||
|
Text(
|
||||||
|
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(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package com.example.mitbringsl.ui.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.mitbringsl.data.auth.AuthRepository
|
||||||
|
import com.example.mitbringsl.data.auth.AuthResult
|
||||||
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
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 AuthUiState(
|
||||||
|
val emailInput: String = "",
|
||||||
|
val passwordInput: String = "",
|
||||||
|
val displayNameInput: String = "",
|
||||||
|
val isRegisteringMode: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
val sessionManager: SessionManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AuthUiState())
|
||||||
|
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun onEmailChanged(value: String) {
|
||||||
|
_uiState.update { it.copy(emailInput = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChanged(value: String) {
|
||||||
|
_uiState.update { it.copy(passwordInput = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDisplayNameChanged(value: String) {
|
||||||
|
_uiState.update { it.copy(displayNameInput = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleMode() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRegisteringMode = !it.isRegisteringMode,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.emailInput.isBlank() || state.passwordInput.isBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Bitte E-Mail und Passwort ausfüllen.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isRegisteringMode && state.passwordInput.length < 8) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Das Passwort muss mindestens 8 Zeichen lang sein.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
val result = if (state.isRegisteringMode) {
|
||||||
|
authRepository.register(
|
||||||
|
email = state.emailInput,
|
||||||
|
pass = state.passwordInput,
|
||||||
|
displayName = state.displayNameInput
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
authRepository.login(
|
||||||
|
email = state.emailInput,
|
||||||
|
pass = state.passwordInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is AuthResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
is AuthResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false, errorMessage = result.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOidcTokenReceived(provider: String, idToken: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
when (val result = authRepository.loginOidc(provider, idToken)) {
|
||||||
|
is AuthResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
is AuthResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false, errorMessage = result.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
package com.example.mitbringsl.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.mitbringsl.data.local.entity.ItemEntity
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ListDetailScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ListDetailViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val items by viewModel.items.collectAsStateWithLifecycle()
|
||||||
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
|
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val (openItems, checkedItems) = remember(items) {
|
||||||
|
items.partition { !it.checked }
|
||||||
|
}
|
||||||
|
|
||||||
|
var quantityInput by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Einkaufsliste", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Zurück"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
// AddItemBar (Bottom bar input with autocomplete)
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Autocomplete Suggestions Dropdown
|
||||||
|
if (suggestions.isNotEmpty() && query.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
suggestions.take(4).forEach { suggestion ->
|
||||||
|
Text(
|
||||||
|
text = suggestion,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
viewModel.addItem(suggestion, quantityInput)
|
||||||
|
quantityInput = ""
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = viewModel::onQueryChanged,
|
||||||
|
placeholder = { Text("Artikel hinzufügen...") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.weight(1.5f),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = quantityInput,
|
||||||
|
onValueChange = { quantityInput = it },
|
||||||
|
placeholder = { Text("Menge") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.weight(0.9f),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
viewModel.addItem(query, quantityInput)
|
||||||
|
quantityInput = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = query.isNotBlank(),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.background(
|
||||||
|
color = if (query.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "Hinzufügen",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(text = "🛒", fontSize = 48.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Die Liste ist noch leer",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Open Items
|
||||||
|
items(openItems, key = { it.id }) { item ->
|
||||||
|
ItemRow(
|
||||||
|
item = item,
|
||||||
|
onToggle = { viewModel.toggleItemChecked(item.id) },
|
||||||
|
onDelete = { viewModel.removeItem(item.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked Items Section Header
|
||||||
|
if (checkedItems.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Erledigt (${checkedItems.size})",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(checkedItems, key = { it.id }) { item ->
|
||||||
|
ItemRow(
|
||||||
|
item = item,
|
||||||
|
onToggle = { viewModel.toggleItemChecked(item.id) },
|
||||||
|
onDelete = { viewModel.removeItem(item.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemRow(
|
||||||
|
item: ItemEntity,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val alpha by animateFloatAsState(if (item.checked) 0.5f else 1.0f, label = "itemAlpha")
|
||||||
|
val cardColor by animateColorAsState(
|
||||||
|
if (item.checked) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
label = "cardColor"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.alpha(alpha),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = cardColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onToggle() }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.checked,
|
||||||
|
onCheckedChange = { onToggle() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
textDecoration = if (item.checked) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
),
|
||||||
|
fontWeight = if (item.checked) FontWeight.Normal else FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (!item.quantity.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = item.quantity,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Entfernen",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package com.example.mitbringsl.ui.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.mitbringsl.data.local.entity.ItemEntity
|
||||||
|
import com.example.mitbringsl.data.remote.api.MitbringslApi
|
||||||
|
import com.example.mitbringsl.data.repository.ShoppingRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class ListDetailViewModel @Inject constructor(
|
||||||
|
private val repository: ShoppingRepository,
|
||||||
|
private val api: MitbringslApi,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val listId: String = checkNotNull(savedStateHandle["listId"])
|
||||||
|
|
||||||
|
val items: StateFlow<List<ItemEntity>> = repository.observeItems(listId)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
private val _suggestions = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val suggestions: StateFlow<List<String>> = _suggestions.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Debounce autocomplete search queries
|
||||||
|
_query
|
||||||
|
.debounce(250)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.onEach { q ->
|
||||||
|
try {
|
||||||
|
val resp = api.getSuggestions(q)
|
||||||
|
if (resp.isSuccessful && resp.body() != null) {
|
||||||
|
_suggestions.value = resp.body()!!.suggestions
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
_suggestions.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQueryChanged(value: String) {
|
||||||
|
_query.value = value
|
||||||
|
if (value.isBlank()) {
|
||||||
|
_suggestions.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addItem(name: String, quantity: String? = null) {
|
||||||
|
if (name.isBlank()) return
|
||||||
|
val qty = quantity?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.addItem(listId, name.trim(), qty)
|
||||||
|
_query.value = ""
|
||||||
|
_suggestions.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleItemChecked(itemId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.toggleItemChecked(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeItem(itemId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.removeItem(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
package com.example.mitbringsl.ui.lists
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
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.Delete
|
||||||
|
import androidx.compose.material.icons.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
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
|
||||||
|
import com.example.mitbringsl.data.local.entity.ListEntity
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ListsScreen(
|
||||||
|
onSelectList: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
||||||
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Meine Listen",
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
viewModel.logout()
|
||||||
|
onLogout()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ExitToApp,
|
||||||
|
contentDescription = "Abmelden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showAddDialog = true },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.Add, contentDescription = "Neue Liste")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
if (lists.isEmpty()) {
|
||||||
|
// Empty state
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.size(88.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(text = "📝", fontSize = 44.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = "Noch keine Einkaufslisten",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Erstelle deine erste Liste mit dem Plus-Button unten rechts.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(lists, key = { it.id }) { list ->
|
||||||
|
ListItemCard(
|
||||||
|
list = list,
|
||||||
|
onClick = { onSelectList(list.id) },
|
||||||
|
onDelete = { viewModel.deleteList(list.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAddDialog) {
|
||||||
|
CreateListDialog(
|
||||||
|
onDismiss = { showAddDialog = false },
|
||||||
|
onConfirm = { name ->
|
||||||
|
viewModel.createList(name)
|
||||||
|
showAddDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListItemCard(
|
||||||
|
list: ListEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() },
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(20.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(44.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ShoppingCart,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = list.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Liste löschen",
|
||||||
|
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateListDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Neue Einkaufsliste") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
label = { Text("Name der Liste") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { onConfirm(text) },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Erstellen")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Abbrechen")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.example.mitbringsl.ui.lists
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
|
import com.example.mitbringsl.data.local.entity.ListEntity
|
||||||
|
import com.example.mitbringsl.data.repository.ShoppingRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ListsViewModel @Inject constructor(
|
||||||
|
private val shoppingRepository: ShoppingRepository,
|
||||||
|
val sessionManager: SessionManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val lists: StateFlow<List<ListEntity>> = shoppingRepository.observeLists()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createList(name: String) {
|
||||||
|
if (name.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
shoppingRepository.createList(name.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteList(listId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
shoppingRepository.deleteList(listId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
sessionManager.clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
|
||||||
# Compose BOM
|
# Compose BOM
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue