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