c60eb8d27b
Build & Release APK / build (push) Has been cancelled
- SyncEngine: accepts onProgress callback — emits uploaded/downloaded/ deleted/bytes counts atomically as each file completes - SyncWorker: streams progress to WorkManager data so the UI can poll it live; reports per-run counters in the completion notification; adds pause/resume support - HomeViewModel/PairDetailViewModel: subscribe to live WorkManager progress and surface it via SyncProgress state - SyncPairEntity/SyncPairDao/SyncDatabase: persist last-run counters (uploaded, downloaded, deleted, bytesTransferred) in the DB with a Room migration (v3→v4) - AppModule: provides WorkManager as an injectable singleton - .gitignore: add .kotlin/ to exclude compiler session files Security: no new issues — all logging via Timber (debug-only), DB queries use Room parameterized API, file sharing via FileProvider. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
334 lines
15 KiB
Kotlin
334 lines
15 KiB
Kotlin
package com.syncflow.ui.home
|
|
|
|
import androidx.compose.animation.core.LinearEasing
|
|
import androidx.compose.animation.core.animateFloat
|
|
import androidx.compose.animation.core.infiniteRepeatable
|
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.BorderStroke
|
|
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.*
|
|
import androidx.compose.material.icons.outlined.*
|
|
import androidx.compose.material3.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import com.syncflow.data.db.entities.SyncPairEntity
|
|
import com.syncflow.domain.model.SyncStatus
|
|
import com.syncflow.ui.shared.SyncProgress
|
|
import java.time.Duration
|
|
import java.time.Instant
|
|
import java.time.ZoneId
|
|
import java.time.format.DateTimeFormatter
|
|
import java.time.format.FormatStyle
|
|
|
|
@Composable
|
|
fun HomeScreen(
|
|
onAddPair: () -> Unit,
|
|
onPairClick: (Long) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
vm: HomeViewModel = hiltViewModel(),
|
|
) {
|
|
val pairs by vm.syncPairs.collectAsState()
|
|
val progressMap by vm.syncProgressMap.collectAsState()
|
|
|
|
if (pairs.isEmpty()) {
|
|
EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair)
|
|
} else {
|
|
LazyColumn(
|
|
modifier = modifier.fillMaxSize(),
|
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
) {
|
|
items(pairs, key = { it.id }) { pair ->
|
|
SyncPairCard(
|
|
pair = pair,
|
|
progress = progressMap[pair.id],
|
|
onClick = { onPairClick(pair.id) },
|
|
onSync = { vm.triggerSync(pair) },
|
|
onToggle = { vm.toggleEnabled(pair) },
|
|
onPause = { vm.pauseSync(pair) },
|
|
)
|
|
}
|
|
item { Spacer(Modifier.height(80.dp)) }
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SyncPairCard(
|
|
pair: SyncPairEntity,
|
|
progress: SyncProgress? = null,
|
|
onClick: () -> Unit,
|
|
onSync: () -> Unit,
|
|
onToggle: () -> Unit,
|
|
onPause: () -> Unit = {},
|
|
) {
|
|
val accentColor = pair.lastSyncResult.accentColor
|
|
|
|
Card(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable(onClick = onClick),
|
|
shape = RoundedCornerShape(16.dp),
|
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
) {
|
|
Row(modifier = Modifier.fillMaxWidth()) {
|
|
// Colored left accent bar
|
|
Box(
|
|
modifier = Modifier
|
|
.width(3.dp)
|
|
.height(IntrinsicSize.Min)
|
|
.defaultMinSize(minHeight = 80.dp),
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxHeight()
|
|
.width(3.dp),
|
|
) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = accentColor,
|
|
) {}
|
|
}
|
|
}
|
|
|
|
Column(modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 12.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(
|
|
pair.name,
|
|
style = MaterialTheme.typography.titleMedium,
|
|
)
|
|
Spacer(Modifier.height(2.dp))
|
|
val localShortName = pair.localPath.toDisplayPath()
|
|
Text(
|
|
localShortName,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(2.dp))
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Icon(
|
|
Icons.Default.ArrowForward,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(12.dp),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Text(
|
|
pair.remotePath,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
Switch(
|
|
checked = pair.isEnabled,
|
|
onCheckedChange = { onToggle() },
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
StatusPill(pair.lastSyncResult)
|
|
if (pair.pendingConflicts > 0) {
|
|
Surface(
|
|
shape = RoundedCornerShape(50),
|
|
color = MaterialTheme.colorScheme.errorContainer,
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
) {
|
|
Icon(Icons.Default.Warning, null, Modifier.size(12.dp), tint = MaterialTheme.colorScheme.error)
|
|
Text(
|
|
"${pair.pendingConflicts} conflicts",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.error,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
Spacer(Modifier.weight(1f))
|
|
pair.lastSyncAt?.let { at ->
|
|
Text(
|
|
at.toRelativeString(),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
val syncRotation by rememberInfiniteTransition(label = "cardSyncSpin").animateFloat(
|
|
initialValue = 0f, targetValue = 360f,
|
|
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
|
|
label = "cardRotation",
|
|
)
|
|
when (pair.lastSyncResult) {
|
|
SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) {
|
|
Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp))
|
|
}
|
|
SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp), enabled = pair.isEnabled) {
|
|
Icon(Icons.Default.PlayArrow, "Resume sync", modifier = Modifier.size(18.dp))
|
|
}
|
|
else -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
|
|
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp).graphicsLayer { rotationZ = syncRotation * 0f })
|
|
}
|
|
}
|
|
}
|
|
|
|
val displayProgress = when {
|
|
pair.lastSyncResult == SyncStatus.SYNCING -> progress
|
|
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
|
|
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
|
|
else -> null
|
|
}
|
|
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
|
|
Text(
|
|
"Starting…",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(top = 4.dp),
|
|
)
|
|
} else if (displayProgress != null) {
|
|
Row(
|
|
modifier = Modifier.padding(top = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
) {
|
|
if (displayProgress.uploaded > 0) {
|
|
Icon(Icons.Default.ArrowUpward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary)
|
|
Text("${displayProgress.uploaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
if (displayProgress.downloaded > 0) {
|
|
Icon(Icons.Default.ArrowDownward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.secondary)
|
|
Text("${displayProgress.downloaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
|
|
}
|
|
if (displayProgress.deleted > 0) {
|
|
Icon(Icons.Default.DeleteOutline, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.error)
|
|
Text("${displayProgress.deleted}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
|
|
}
|
|
if (displayProgress.bytesTransferred > 0) {
|
|
Text(
|
|
"· ${displayProgress.bytesTransferred.toDisplaySize()}",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun StatusPill(status: SyncStatus) {
|
|
val (icon, label) = when (status) {
|
|
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
|
|
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
|
|
SyncStatus.PAUSED -> Pair(Icons.Default.Pause, "Paused")
|
|
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
|
|
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
|
|
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
|
|
SyncStatus.IDLE -> Pair(Icons.Outlined.Circle, "Idle")
|
|
}
|
|
val containerColor = status.accentColor
|
|
val rotation by rememberInfiniteTransition(label = "syncSpin").animateFloat(
|
|
initialValue = 0f, targetValue = 360f,
|
|
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
|
|
label = "rotation",
|
|
)
|
|
Surface(
|
|
shape = RoundedCornerShape(50),
|
|
color = containerColor.copy(alpha = 0.15f),
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
) {
|
|
Icon(
|
|
icon, null,
|
|
Modifier.size(12.dp).graphicsLayer { if (status == SyncStatus.SYNCING) rotationZ = rotation },
|
|
tint = containerColor,
|
|
)
|
|
Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
|
|
Column(
|
|
modifier = modifier,
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center,
|
|
) {
|
|
Icon(
|
|
Icons.Outlined.CloudSync,
|
|
null,
|
|
modifier = Modifier.size(100.dp),
|
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
|
|
)
|
|
Spacer(Modifier.height(16.dp))
|
|
Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge)
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
"Tap + to create your first sync",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(24.dp))
|
|
FilledTonalButton(onClick = onAdd) { Text("Add Sync Pair") }
|
|
}
|
|
}
|
|
|
|
private fun Long.toDisplaySize(): String = when {
|
|
this < 1_024 -> "$this B"
|
|
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
|
|
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
|
|
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
|
|
}
|
|
|
|
private val SyncStatus.accentColor: Color
|
|
@Composable get() = when (this) {
|
|
SyncStatus.SUCCESS -> Color(0xFF2E7D32)
|
|
SyncStatus.SYNCING -> Color(0xFF1565C0)
|
|
SyncStatus.PAUSED -> Color(0xFF6A1B9A)
|
|
SyncStatus.FAILED -> Color(0xFFC62828)
|
|
SyncStatus.PARTIAL -> Color(0xFFE65100)
|
|
SyncStatus.CONFLICT -> Color(0xFFF9A825)
|
|
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
|
|
}
|
|
|
|
private fun String.toDisplayPath(): String {
|
|
// For SAF content:// URIs, decode the last path segment (e.g. primary%3ASyncFlowTest → SyncFlowTest)
|
|
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
|
|
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
|
|
}
|
|
|
|
private fun Instant.toRelativeString(): String {
|
|
val diff = Duration.between(this, Instant.now()).abs()
|
|
return when {
|
|
diff.toMinutes() < 1 -> "Just now"
|
|
diff.toMinutes() < 60 -> "${diff.toMinutes()} min ago"
|
|
diff.toHours() < 24 -> "${diff.toHours()} hr ago"
|
|
diff.toDays() == 1L -> "Yesterday"
|
|
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
|
|
.format(atZone(ZoneId.systemDefault()))
|
|
}
|
|
}
|