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.
|
||||
**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 <net> \
|
|||
|
||||
## 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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Main> {
|
||||
MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp))
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = entryProvider {
|
||||
entry<AuthNavKey> {
|
||||
AuthScreen(
|
||||
onLoginSuccess = {
|
||||
backStack.clear()
|
||||
backStack.add(ListsNavKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<ListsNavKey> {
|
||||
ListsScreen(
|
||||
onSelectList = { listId ->
|
||||
backStack.add(ListDetailNavKey(listId))
|
||||
},
|
||||
onLogout = {
|
||||
backStack.clear()
|
||||
backStack.add(AuthNavKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<ListDetailNavKey> { key ->
|
||||
ListDetailScreen(
|
||||
onBack = { backStack.removeLastOrNull() }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue