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:
Tronax 2026-08-05 20:12:28 +02:00
parent e44d645112
commit 174aad535a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
18 changed files with 1789 additions and 37 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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