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