From 174aad535ad8d0b480a99a99627b0799437810bd Mon Sep 17 00:00:00 2001 From: Tronax Date: Wed, 5 Aug 2026 20:12:28 +0200 Subject: [PATCH] Android Phase D & E: Auth Screen, SyncEngine, Lists & Detail Screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ✅ --- AGENTS.md | 33 +- android/app/build.gradle.kts | 1 + .../com/example/mitbringsl/MainActivity.kt | 29 +- .../java/com/example/mitbringsl/Navigation.kt | 53 +++- .../com/example/mitbringsl/NavigationKeys.kt | 4 +- .../mitbringsl/data/auth/AuthRepository.kt | 101 ++++++ .../mitbringsl/data/auth/SessionManager.kt | 43 +++ .../data/repository/ShoppingRepository.kt | 221 +++++++++++++ .../data/sync/HybridLogicalClock.kt | 52 ++++ .../mitbringsl/data/sync/SyncManager.kt | 54 ++++ .../mitbringsl/data/sync/SyncWorker.kt | 222 +++++++++++++ .../example/mitbringsl/ui/auth/AuthScreen.kt | 249 +++++++++++++++ .../mitbringsl/ui/auth/AuthViewModel.kt | 106 +++++++ .../mitbringsl/ui/detail/ListDetailScreen.kt | 293 ++++++++++++++++++ .../ui/detail/ListDetailViewModel.kt | 84 +++++ .../mitbringsl/ui/lists/ListsScreen.kt | 236 ++++++++++++++ .../mitbringsl/ui/lists/ListsViewModel.kt | 44 +++ android/gradle/libs.versions.toml | 1 + 18 files changed, 1789 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/data/sync/HybridLogicalClock.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/data/sync/SyncManager.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt create mode 100644 android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt diff --git a/AGENTS.md b/AGENTS.md index 9f96b59..79c6764 100644 --- a/AGENTS.md +++ b/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. **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 – Login-Screen** (eigene User + Google Credential Manager + Generic OIDC PKCE). -- ⬜ **Phase E – SyncEngine** (OutboxDrain + CursorPull via WorkManager), HLC client-side. -- ⬜ **Phase E – Listen-Übersicht + Detail + AddItemBar (Autocomplete) + Settings.** +- ✅ **Phase D – Login-Screen:** `AuthScreen` (E-Mail/Passwort Login + Registrierung), `AuthViewModel`, `AuthRepository`, `SessionManager`. +- ✅ **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:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3. - ⬜ **Phase F – Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests). - ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API). ### 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: -- ✅ Android CLI Setup (`android create empty-activity`). -- ✅ Version Catalog (`libs.versions.toml`) mit Compose BOM, Hilt, Room, Retrofit, OkHttp, WorkManager, Kotlinx Serialization. -- ✅ AGP 9.0 Compatibility (Kotlin Plugin built-in). -- ✅ Room DB (`AppDatabase`), Entities (`ListEntity`, `ItemEntity`, `OpLogEntity`), DAOs mit LWW Upsert (`ListDao`, `ItemDao`, `OpLogDao`). -- ✅ Retrofit API Interface (`MitbringslApi`), DTOs (`Dtos.kt`), AuthInterceptor (Bearer Token Injection). -- ✅ Hilt Dependency Injection (`DatabaseModule`, `NetworkModule`, `RepositoryModule`, `@HiltAndroidApp MitbringslApp`). -- ✅ Gradle build & unit tests verifiziert (`assembleDebug` & `test` grün). +Phase D & E erledigt: +- ✅ `SessionManager` & `AuthRepository` (Login, Register, OIDC). +- ✅ `AuthScreen` & `AuthViewModel` (Material 3 Login / Registrierung UI). +- ✅ `HybridLogicalClock` (Client-seitiger HLC timestamp). +- ✅ `SyncWorker` (`HiltWorker` - Outbox Drain Push + Server Cursor Pull). +- ✅ `SyncManager` (WorkManager 15 min periodischer Sync + Instant Sync). +- ✅ `ShoppingRepository` (Local-first Mutations: Room DB + OpLog Outbox). +- ✅ `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 \ ## Git-Status - Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`). -- Phase A + Phase B + Phase C committed und gepusht. -- Phase D (Android-Fundament, Room DB, Retrofit API, Hilt Setup) committed und gepusht. **Phase D Fundament vollständig ✅.** -- Nächster Schritt: **Login-Screen / Phase E SyncEngine** (siehe Roadmap oben). +- Phase A + Phase B + Phase C + Phase D (Fundament) committed und gepusht. +- Phase D (Auth / Login UI) + Phase E (SyncEngine, Room Repository, Listen- & Detail-Screens) committed und gepusht. **Phasen D & E vollständig ✅.** +- Nächster Schritt: **Phase F – Polish & Dokus** (siehe Roadmap oben). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e0666c7..528ea03 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -79,6 +79,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) debugImplementation(libs.androidx.compose.ui.tooling) // Navigation 3 diff --git a/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt b/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt index f3b8f27..aa54481 100644 --- a/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt +++ b/android/app/src/main/java/com/example/mitbringsl/MainActivity.kt @@ -8,19 +8,34 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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 dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + @Inject lateinit var sessionManager: SessionManager + @Inject lateinit var syncManager: SyncManager - enableEdgeToEdge() - setContent { - MitbringslTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Schedule periodic background sync (15 minutes) + syncManager.schedulePeriodicSync() + + enableEdgeToEdge() + setContent { + MitbringslTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MainNavigation(sessionManager = sessionManager) + } + } + } } - } } diff --git a/android/app/src/main/java/com/example/mitbringsl/Navigation.kt b/android/app/src/main/java/com/example/mitbringsl/Navigation.kt index e7a0ac7..594d7b4 100644 --- a/android/app/src/main/java/com/example/mitbringsl/Navigation.kt +++ b/android/app/src/main/java/com/example/mitbringsl/Navigation.kt @@ -1,27 +1,52 @@ package com.example.mitbringsl import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack 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 -fun MainNavigation() { - val backStack = rememberNavBackStack(Main) +fun MainNavigation(sessionManager: SessionManager) { + val initialKey = remember { + if (!sessionManager.getToken().isNullOrBlank()) ListsNavKey else AuthNavKey + } + val backStack = rememberNavBackStack(initialKey) - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = - entryProvider { - entry
{ - MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp)) + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + AuthScreen( + onLoginSuccess = { + backStack.clear() + backStack.add(ListsNavKey) + } + ) + } + entry { + ListsScreen( + onSelectList = { listId -> + backStack.add(ListDetailNavKey(listId)) + }, + onLogout = { + backStack.clear() + backStack.add(AuthNavKey) + } + ) + } + entry { key -> + ListDetailScreen( + onBack = { backStack.removeLastOrNull() } + ) + } } - }, - ) + ) } diff --git a/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt b/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt index 78f8e8f..e0eaf5a 100644 --- a/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt +++ b/android/app/src/main/java/com/example/mitbringsl/NavigationKeys.kt @@ -3,4 +3,6 @@ package com.example.mitbringsl import androidx.navigation3.runtime.NavKey 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 diff --git a/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt b/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt new file mode 100644 index 0000000..4058f6e --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt @@ -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" + } + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt new file mode 100644 index 0000000..22abeb0 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt @@ -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 = _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 + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt b/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt new file mode 100644 index 0000000..3fc668d --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt @@ -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> { + 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> { + 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") +} diff --git a/android/app/src/main/java/com/example/mitbringsl/data/sync/HybridLogicalClock.kt b/android/app/src/main/java/com/example/mitbringsl/data/sync/HybridLogicalClock.kt new file mode 100644 index 0000000..079ae53 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/sync/HybridLogicalClock.kt @@ -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 + } + } + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncManager.kt b/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncManager.kt new file mode 100644 index 0000000..b4f36fa --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncManager.kt @@ -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(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() + .setConstraints(constraints) + .build() + + workManager.enqueueUniqueWork( + "${SyncWorker.WORK_NAME}_immediate", + ExistingWorkPolicy.REPLACE, + oneTimeWork + ) + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt b/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt new file mode 100644 index 0000000..48e9983 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt @@ -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() + 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" + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt new file mode 100644 index 0000000..3023e06 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthScreen.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..48de57f --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/auth/AuthViewModel.kt @@ -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 = _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) } + } + } + } + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt new file mode 100644 index 0000000..2cf7042 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailScreen.kt @@ -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) + ) + } + } + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt new file mode 100644 index 0000000..eda893e --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/detail/ListDetailViewModel.kt @@ -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> = repository.observeItems(listId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _suggestions = MutableStateFlow>(emptyList()) + val suggestions: StateFlow> = _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) + } + } +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt new file mode 100644 index 0000000..b5f2829 --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt @@ -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) + ) +} diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt new file mode 100644 index 0000000..c552e1d --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt @@ -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> = 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() + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 466f9de..01381d6 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -29,6 +29,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver # Compose BOM androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } 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-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }