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>
299 lines
13 KiB
Kotlin
299 lines
13 KiB
Kotlin
package com.syncflow.ui.pairdetail
|
|
|
|
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.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.Circle
|
|
import androidx.compose.material3.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
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 com.syncflow.ui.shared.SyncEventRow
|
|
import java.time.Duration
|
|
import java.time.Instant
|
|
import java.time.ZoneId
|
|
import java.time.format.DateTimeFormatter
|
|
import java.time.format.FormatStyle
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun PairDetailScreen(
|
|
onBack: () -> Unit,
|
|
onEdit: (Long) -> Unit,
|
|
onConflicts: (Long) -> Unit,
|
|
vm: PairDetailViewModel = hiltViewModel(),
|
|
) {
|
|
val pair by vm.pair.collectAsState()
|
|
val events by vm.events.collectAsState()
|
|
val conflictCount by vm.unresolvedConflicts.collectAsState()
|
|
val syncProgress by vm.syncProgress.collectAsState()
|
|
var showDelete by remember { mutableStateOf(false) }
|
|
|
|
if (showDelete) {
|
|
AlertDialog(
|
|
onDismissRequest = { showDelete = false },
|
|
title = { Text("Delete sync pair?") },
|
|
text = { Text("This removes the pair and all sync history. Files are NOT deleted.") },
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = { vm.delete(); onBack() },
|
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
|
) {
|
|
Text("Delete")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = { showDelete = false }) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
title = { Text(pair?.name ?: "…") },
|
|
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
|
|
actions = {
|
|
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") }
|
|
when (pair?.lastSyncResult) {
|
|
SyncStatus.SYNCING -> IconButton(onClick = { vm.pauseSync() }) {
|
|
Icon(Icons.Default.Pause, "Pause sync")
|
|
}
|
|
SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }, enabled = pair?.isEnabled == true) {
|
|
Icon(Icons.Default.PlayArrow, "Resume sync")
|
|
}
|
|
else -> IconButton(onClick = { vm.syncNow() }) {
|
|
Icon(Icons.Default.Sync, "Sync now")
|
|
}
|
|
}
|
|
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
|
|
},
|
|
)
|
|
},
|
|
) { padding ->
|
|
LazyColumn(
|
|
modifier = Modifier.padding(padding),
|
|
contentPadding = PaddingValues(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
) {
|
|
item {
|
|
pair?.let { p -> StatusBanner(p, syncProgress) }
|
|
}
|
|
|
|
item {
|
|
pair?.let { p ->
|
|
InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name)
|
|
}
|
|
}
|
|
|
|
if (conflictCount > 0) {
|
|
item {
|
|
FilledTonalButton(
|
|
onClick = { pair?.let { onConflicts(it.id) } },
|
|
modifier = Modifier.fillMaxWidth(),
|
|
colors = ButtonDefaults.filledTonalButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
|
),
|
|
) {
|
|
Icon(Icons.Default.Warning, null)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("$conflictCount unresolved conflict${if (conflictCount != 1) "s" else ""}")
|
|
}
|
|
}
|
|
}
|
|
|
|
item {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.padding(top = 4.dp),
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.width(3.dp)
|
|
.height(16.dp)
|
|
.clip(RoundedCornerShape(2.dp)),
|
|
) {
|
|
Surface(color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxSize()) {}
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Activity", style = MaterialTheme.typography.titleSmall)
|
|
}
|
|
HorizontalDivider(modifier = Modifier.padding(top = 4.dp))
|
|
}
|
|
|
|
if (events.isEmpty()) {
|
|
item {
|
|
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
|
Text("No sync activity yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
} else {
|
|
items(events, key = { it.id }) { event ->
|
|
SyncEventRow(event, showDivider = event != events.last())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun StatusBanner(pair: SyncPairEntity, progress: SyncProgress? = null) {
|
|
val (icon, label, containerColor) = when (pair.lastSyncResult) {
|
|
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
|
|
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
|
|
SyncStatus.PAUSED -> Triple(Icons.Default.Pause, "Paused — tap ▶ to resume", MaterialTheme.colorScheme.surfaceVariant)
|
|
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
|
|
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
|
|
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
|
|
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
|
|
}
|
|
val rotation by rememberInfiniteTransition(label = "bannerSpin").animateFloat(
|
|
initialValue = 0f, targetValue = 360f,
|
|
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
|
|
label = "bannerRotation",
|
|
)
|
|
Surface(
|
|
color = containerColor,
|
|
shape = RoundedCornerShape(16.dp),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Row(
|
|
modifier = Modifier.padding(16.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Icon(
|
|
icon, null,
|
|
modifier = Modifier.size(40.dp).graphicsLayer {
|
|
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = rotation
|
|
},
|
|
)
|
|
Spacer(Modifier.width(16.dp))
|
|
Column {
|
|
Text(label, style = MaterialTheme.typography.titleMedium)
|
|
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.bodySmall)
|
|
} else if (displayProgress != null) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
) {
|
|
if (displayProgress.uploaded > 0) {
|
|
Icon(Icons.Default.ArrowUpward, null, Modifier.size(12.dp))
|
|
Text("${displayProgress.uploaded} up", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
if (displayProgress.downloaded > 0) {
|
|
Icon(Icons.Default.ArrowDownward, null, Modifier.size(12.dp))
|
|
Text("${displayProgress.downloaded} down", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
if (displayProgress.deleted > 0) {
|
|
Icon(Icons.Default.DeleteOutline, null, Modifier.size(12.dp))
|
|
Text("${displayProgress.deleted} del", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
if (displayProgress.bytesTransferred > 0) {
|
|
Text("· ${displayProgress.bytesTransferred.toDisplaySize()}", style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
} else {
|
|
pair.lastSyncAt?.let {
|
|
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) {
|
|
Card(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
) {
|
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath.toDisplayPath())
|
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
|
InfoRow(Icons.Default.Cloud, "Remote", remotePath)
|
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
|
Row {
|
|
InfoRow(Icons.Default.SwapHoriz, "Direction", direction, modifier = Modifier.weight(1f))
|
|
InfoRow(Icons.Default.Schedule, "Schedule", schedule, modifier = Modifier.weight(1f))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun InfoRow(
|
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
label: String,
|
|
value: String,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
|
Surface(
|
|
shape = RoundedCornerShape(8.dp),
|
|
color = MaterialTheme.colorScheme.primaryContainer,
|
|
modifier = Modifier.size(28.dp),
|
|
) {
|
|
Box(contentAlignment = Alignment.Center) {
|
|
Icon(
|
|
icon,
|
|
null,
|
|
Modifier.size(16.dp),
|
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
)
|
|
}
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
Column {
|
|
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Text(value, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 fun String.toDisplayPath(): String {
|
|
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()))
|
|
}
|
|
}
|