Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt
T
amir b15637132c v1.0.16: spinning sync icon, colorful icon, ON_CHANGE fix, notification fix
- Sync icon now rotates (CSS-style spin) in StatusPill, StatusBanner,
  and card sync button whenever status is SYNCING
- Launcher icon redesigned: indigo→violet→cyan gradient background,
  upload arrow fades white→sky-blue, download arrow fades white→violet,
  soft glow ring behind arrows
- Fix ON_CHANGE not triggering: FileWatchService.start() now called
  from AddPairViewModel.save() so pairs created with ON_CHANGE
  immediately begin watching without needing a toggle or reboot
- Fix FileWatch notification hidden: IMPORTANCE_MIN → IMPORTANCE_LOW
  so the "Watching N folders" notification shows in the shade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:42:30 +00:00

275 lines
11 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 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()))
}
}