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 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() 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, onClick = { onPairClick(pair.id) }, onSync = { vm.triggerSync(pair) }, onToggle = { vm.toggleEnabled(pair) }, ) } item { Spacer(Modifier.height(80.dp)) } } } } @Composable private fun SyncPairCard( pair: SyncPairEntity, onClick: () -> Unit, onSync: () -> Unit, onToggle: () -> 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", ) FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { Icon( Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp).graphicsLayer { if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation }, ) } } } } } } @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.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 val SyncStatus.accentColor: Color @Composable get() = when (this) { SyncStatus.SUCCESS -> MaterialTheme.colorScheme.primary SyncStatus.SYNCING -> MaterialTheme.colorScheme.secondary SyncStatus.FAILED -> MaterialTheme.colorScheme.error SyncStatus.CONFLICT, SyncStatus.PARTIAL -> MaterialTheme.colorScheme.tertiary 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())) } }