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:
parent
174aad535a
commit
a00db14cba
8 changed files with 482 additions and 96 deletions
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue