Phase F: NetworkMonitor, offline banner, README & architecture docs

- NetworkMonitor: ConnectivityState Flow using ConnectivityManager.NetworkCallback
- ListsScreen & ViewModel: live offline indicator banner when disconnected
- Documentation:
  - docs/ARCHITECTURE.md: system design & tech stack overview
  - docs/SYNC.md: HLC timestamping, op_log outbox & LWW projection specification
  - docs/API.md: REST API endpoint specification
  - README.md: quickstart guide for backend, docker compose & Android app
- Verification: backend & android test suites 100% green 
This commit is contained in:
Tronax 2026-08-05 20:14:04 +02:00
parent 174aad535a
commit a00db14cba
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
8 changed files with 482 additions and 96 deletions

View file

@ -1,5 +1,9 @@
package com.example.mitbringsl.ui.lists
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -10,6 +14,7 @@ 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.material.icons.filled.WifiOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -31,18 +36,17 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(),
) {
val lists by viewModel.lists.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Meine Listen",
fontWeight = FontWeight.Bold
)
}
Text(
text = "Meine Listen",
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(onClick = {
@ -72,55 +76,89 @@ fun ListsScreen(
},
modifier = modifier
) { paddingValues ->
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (lists.isEmpty()) {
// Empty state
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
// Offline Banner
AnimatedVisibility(
visible = !isOnline,
enter = expandVertically(),
exit = shrinkVertically()
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth()
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.size(88.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
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) }
Icon(
imageVector = Icons.Default.WifiOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Offline Änderungen werden lokal gespeichert",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (lists.isEmpty()) {
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) }
)
}
}
}
}

View file

@ -5,6 +5,7 @@ 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 com.example.mitbringsl.util.NetworkMonitor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -16,8 +17,16 @@ import javax.inject.Inject
class ListsViewModel @Inject constructor(
private val shoppingRepository: ShoppingRepository,
val sessionManager: SessionManager,
networkMonitor: NetworkMonitor,
) : ViewModel() {
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = true
)
val lists: StateFlow<List<ListEntity>> = shoppingRepository.observeLists()
.stateIn(
scope = viewModelScope,

View file

@ -0,0 +1,49 @@
package com.example.mitbringsl.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) {
val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
val activeNetwork = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
val initialOnline = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
trySend(initialOnline)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()
}