ui: Server-Synchronisierung als ElevatedCard neu gestaltet

- Status-Dot + Verbindungstext links, Sync-Button (Refresh-Icon) rechts
  in einer kompakten Zeile statt verstreuter Einzelelemente
- Bei Disconnect: Haupttext 'Keine Verbindung', Countdown als
  zweite Zeile darunter (statt alles in einer langen Zeile)
- Sync-Spinner ersetzt den Button solange Sync läuft
- Aktivitätsmeldung animiert unter der Statuszeile
- Letzte-Sync-Zeit mit Divider am Card-Boden, klar abgetrennt
- 'Synchronisierung erfolgt automatisch.' entfernt (redundant)
This commit is contained in:
Jens Reinemann 2026-05-17 20:35:15 +02:00
parent fdc016c786
commit 9ff21cbc4b

View file

@ -22,12 +22,15 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@ -47,16 +50,21 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.bollwerk.app.BuildConfig
import de.bollwerk.app.data.sync.ConnectionState import de.bollwerk.app.data.sync.ConnectionState
import de.bollwerk.app.domain.model.AgeGroup import de.bollwerk.app.domain.model.AgeGroup
import de.bollwerk.app.domain.model.totalDailyKcal import de.bollwerk.app.domain.model.totalDailyKcal
import de.bollwerk.app.ui.update.UpdateStatus
import de.bollwerk.app.ui.update.UpdateViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun SettingsScreen( internal fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel() viewModel: SettingsViewModel = hiltViewModel(),
updateViewModel: UpdateViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val updateState by updateViewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
@ -363,48 +371,12 @@ internal fun SettingsScreen(
uiState.serverUrl.isNotBlank() && uiState.serverUrl.isNotBlank() &&
!uiState.isSyncing !uiState.isSyncing
ConnectionStatusIndicator(uiState = uiState) SyncStatusCard(
uiState = uiState,
Spacer(modifier = Modifier.height(8.dp)) isSyncEnabled = isSyncEnabled,
onSyncClick = viewModel::pullSync
Text(
text = "Synchronisierung erfolgt automatisch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::pullSync,
enabled = isSyncEnabled,
) {
Text("Jetzt synchronisieren")
}
if (uiState.isSyncing) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Synchronisierung läuft…",
style = MaterialTheme.typography.bodySmall
)
}
}
if (uiState.lastSyncTime != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Letzte Synchronisierung: ${uiState.lastSyncTime}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (de.bollwerk.app.BuildConfig.FEATURE_CAMERA_ENABLED) { if (de.bollwerk.app.BuildConfig.FEATURE_CAMERA_ENABLED) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@ -436,6 +408,90 @@ internal fun SettingsScreen(
color = MaterialTheme.colorScheme.secondary color = MaterialTheme.colorScheme.secondary
) )
} }
Spacer(modifier = Modifier.height(32.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "App",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Version",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "v${BuildConfig.VERSION_NAME} (Build ${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
when (val s = updateState.status) {
is UpdateStatus.Available -> Text(
text = "Update v${s.versionName} verfügbar",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
is UpdateStatus.Checking -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text("Suche nach Updates…", style = MaterialTheme.typography.bodySmall)
}
is UpdateStatus.Downloading -> Column {
Text("Lädt herunter…", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { s.progress },
modifier = Modifier.fillMaxWidth()
)
}
is UpdateStatus.ReadyToInstall -> Text(
text = "v${s.versionName} bereit zur Installation",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
is UpdateStatus.Error -> Text(
text = s.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
is UpdateStatus.Hidden -> {}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = updateViewModel::checkForUpdate,
enabled = updateState.status !is UpdateStatus.Checking &&
updateState.status !is UpdateStatus.Downloading,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.SystemUpdate,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Auf Updates prüfen")
}
Spacer(modifier = Modifier.height(16.dp))
} }
} }
} }
@ -488,46 +544,87 @@ internal fun SettingsScreen(
} }
@Composable @Composable
private fun ConnectionStatusIndicator(uiState: SettingsUiState) { private fun SyncStatusCard(
Column { uiState: SettingsUiState,
Row( isSyncEnabled: Boolean,
verticalAlignment = Alignment.CenterVertically, onSyncClick: () -> Unit
horizontalArrangement = Arrangement.spacedBy(8.dp) ) {
) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
val (dotColor, statusText) = when (val state = uiState.connectionState) { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
is ConnectionState.Connected -> Color(0xFF4CAF50) to "Verbunden" Row(
is ConnectionState.Connecting -> Color(0xFFFFC107) to "Verbinde…" modifier = Modifier.fillMaxWidth(),
is ConnectionState.Disconnected -> Color(0xFFF44336) to verticalAlignment = Alignment.CenterVertically,
"Keine Verbindung nächster Versuch in ${state.reconnectInSeconds} Sek." horizontalArrangement = Arrangement.SpaceBetween
is ConnectionState.NotConfigured -> Color.Gray to "Nicht angemeldet" ) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val (dotColor, statusText) = when (uiState.connectionState) {
is ConnectionState.Connected -> Color(0xFF4CAF50) to "Verbunden"
is ConnectionState.Connecting -> Color(0xFFFFC107) to "Verbinde…"
is ConnectionState.Disconnected -> Color(0xFFF44336) to "Keine Verbindung"
is ConnectionState.NotConfigured -> Color.Gray to "Nicht angemeldet"
}
val disconnectedSeconds =
(uiState.connectionState as? ConnectionState.Disconnected)?.reconnectInSeconds
Text(
text = "",
color = dotColor,
style = MaterialTheme.typography.bodyLarge
)
Column {
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium
)
if (disconnectedSeconds != null) {
Text(
text = "Nächster Versuch in $disconnectedSeconds Sek.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (uiState.isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
IconButton(
onClick = onSyncClick,
enabled = isSyncEnabled
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Jetzt synchronisieren"
)
}
}
} }
AnimatedVisibility(
Text( visible = uiState.syncActivity != null,
text = "", enter = fadeIn(),
color = dotColor, exit = fadeOut()
style = MaterialTheme.typography.bodyLarge ) {
) uiState.syncActivity?.let { activity ->
Text( Text(
text = statusText, text = activity.text,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodySmall,
) color = if (activity.isError) MaterialTheme.colorScheme.error
} else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 26.dp, top = 4.dp)
AnimatedVisibility( )
visible = uiState.syncActivity != null, }
enter = fadeIn(), }
exit = fadeOut() if (uiState.lastSyncTime != null) {
) { HorizontalDivider(modifier = Modifier.padding(top = 12.dp, bottom = 8.dp))
uiState.syncActivity?.let { activity ->
Text( Text(
text = activity.text, text = "Letzte Synchronisierung: ${uiState.lastSyncTime}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (activity.isError) { color = MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 20.dp, top = 4.dp)
) )
} }
} }