Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt
T
amir c60eb8d27b
Build & Release APK / build (push) Has been cancelled
v1.0.63: live sync progress counters, pause/resume, .gitignore fix
- 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>
2026-05-27 20:07:25 +00:00

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()))
}
}