diff --git a/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt index 847d788..1ade924 100644 --- a/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt @@ -1,9 +1,11 @@ package com.syncflow.ui.home +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.* @@ -11,11 +13,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.Color 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 @@ -58,49 +61,107 @@ private fun SyncPairCard( onSync: () -> Unit, onToggle: () -> Unit, ) { - ElevatedCard( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), + 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), ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(pair.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(2.dp)) - Text( - pair.localPath.takeLast(40), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch(checked = pair.isEnabled, onCheckedChange = { onToggle() }) - } - Spacer(Modifier.height(10.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + Row(modifier = Modifier.fillMaxWidth()) { + // Colored left accent bar + Box( + modifier = Modifier + .width(3.dp) + .height(IntrinsicSize.Min) + .defaultMinSize(minHeight = 80.dp), ) { - StatusChip(pair.lastSyncResult) - if (pair.pendingConflicts > 0) { - AssistChip( - onClick = {}, - label = { Text("${pair.pendingConflicts} conflicts") }, - leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) }, - colors = AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), + 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.weight(1f)) - pair.lastSyncAt?.let { at -> - Text( - at.formatRelative(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { - Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp)) + 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, + ) + } + FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp)) + } } } } @@ -108,21 +169,33 @@ private fun SyncPairCard( } @Composable -private fun StatusChip(status: SyncStatus) { - val (icon, label, color) = when (status) { - SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer) - SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer) - 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) +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 contentColor = when (status) { + SyncStatus.IDLE -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.surface + } + 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), tint = containerColor) + Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor) + } } - AssistChip( - onClick = {}, - label = { Text(label) }, - leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) }, - colors = AssistChipDefaults.assistChipColors(containerColor = color), - ) } @Composable @@ -132,15 +205,49 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon(Icons.Outlined.CloudSync, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary) + 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, fontWeight = FontWeight.Medium) + 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) + Text( + "Tap + to create your first sync", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.height(24.dp)) - Button(onClick = onAdd) { Text("Add Sync Pair") } + FilledTonalButton(onClick = onAdd) { Text("Add Sync Pair") } } } -private val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) -private fun Instant.formatRelative(): String = fmt.format(atZone(ZoneId.systemDefault())) +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())) + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt index de77fd4..662d53d 100644 --- a/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt +++ b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt @@ -6,8 +6,15 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -19,8 +26,11 @@ import androidx.compose.material.icons.outlined.Sync import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.syncflow.R import com.syncflow.ui.home.HomeScreen import com.syncflow.ui.settings.SettingsScreen import kotlinx.coroutines.launch @@ -38,11 +48,26 @@ fun MainShell( Scaffold( topBar = { - TopAppBar( - title = { Text("SyncFlow", fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( + CenterAlignedTopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_sync), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + "SyncFlow", + style = MaterialTheme.typography.titleLarge, + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.surface, ), + modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), ) }, bottomBar = { @@ -81,6 +106,8 @@ fun MainShell( text = { Text("Add Sync") }, icon = { Icon(Icons.Default.Add, null) }, onClick = onAddPair, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, ) } }, diff --git a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt index 5440d00..f8ba538 100644 --- a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt @@ -3,16 +3,24 @@ package com.syncflow.ui.pairdetail 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.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.data.db.entities.SyncEventEntity +import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.domain.model.SyncEventType +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 @@ -36,7 +44,10 @@ fun PairDetailScreen( 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)) { + TextButton( + onClick = { vm.delete(); onBack() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Delete") } }, @@ -60,8 +71,12 @@ fun PairDetailScreen( LazyColumn( modifier = Modifier.padding(padding), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + pair?.let { p -> StatusBanner(p) } + } + item { pair?.let { p -> InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name) @@ -85,8 +100,22 @@ fun PairDetailScreen( } item { - Text("Activity", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + 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()) { @@ -105,24 +134,84 @@ fun PairDetailScreen( } @Composable -private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - InfoRow(Icons.Default.PhoneAndroid, "Local", localPath) - InfoRow(Icons.Default.Cloud, "Remote", remotePath) - InfoRow(Icons.Default.SwapHoriz, "Direction", direction) - InfoRow(Icons.Default.Schedule, "Schedule", schedule) +private fun StatusBanner(pair: SyncPairEntity) { + 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.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) + } + 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)) + Spacer(Modifier.width(16.dp)) + Column { + Text(label, style = MaterialTheme.typography.titleMedium) + pair.lastSyncAt?.let { + Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall) + } + } } } } @Composable -private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(icon, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) +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)) - Text("$label: ", style = MaterialTheme.typography.labelMedium) - Text(value, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Column { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodySmall) + } } } @@ -130,38 +219,65 @@ private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label private fun EventRow(event: SyncEventEntity) { val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) val zone = ZoneId.systemDefault() - val (icon, tint) = eventIcon(event.eventType) + val dotColor = eventColor(event.eventType) Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(icon, null, Modifier.size(16.dp), tint = tint) - Spacer(Modifier.width(8.dp)) + // Colored dot indicator + Surface( + shape = RoundedCornerShape(50), + color = dotColor, + modifier = Modifier.size(8.dp), + ) {} + Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { - Text(event.filePath ?: event.message ?: event.eventType.name, style = MaterialTheme.typography.bodySmall) + Text( + event.filePath ?: event.message ?: event.eventType.name, + style = MaterialTheme.typography.bodySmall, + ) event.message?.takeIf { event.filePath != null }?.let { - Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - Text(fmt.format(event.timestamp.atZone(zone)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + fmt.format(event.timestamp.atZone(zone)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @Composable -private fun eventIcon(type: SyncEventType): Pair { - val green = MaterialTheme.colorScheme.primary - val red = MaterialTheme.colorScheme.error - val orange = MaterialTheme.colorScheme.tertiary - val grey = MaterialTheme.colorScheme.onSurfaceVariant - return when (type) { - SyncEventType.SYNC_STARTED -> Pair(Icons.Default.PlayArrow, green) - SyncEventType.SYNC_COMPLETED -> Pair(Icons.Default.CheckCircle, green) - SyncEventType.SYNC_FAILED -> Pair(Icons.Default.Error, red) - SyncEventType.FILE_UPLOADED -> Pair(Icons.Default.Upload, green) - SyncEventType.FILE_DOWNLOADED -> Pair(Icons.Default.Download, green) - SyncEventType.FILE_DELETED -> Pair(Icons.Default.Delete, orange) - SyncEventType.FILE_SKIPPED -> Pair(Icons.Default.SkipNext, grey) - SyncEventType.CONFLICT_DETECTED -> Pair(Icons.Default.Warning, orange) - SyncEventType.CONFLICT_RESOLVED -> Pair(Icons.Default.CheckCircle, green) +private fun eventColor(type: SyncEventType): Color = when (type) { + SyncEventType.SYNC_STARTED -> MaterialTheme.colorScheme.primary + SyncEventType.SYNC_COMPLETED -> MaterialTheme.colorScheme.primary + SyncEventType.SYNC_FAILED -> MaterialTheme.colorScheme.error + SyncEventType.FILE_UPLOADED -> MaterialTheme.colorScheme.secondary + SyncEventType.FILE_DOWNLOADED -> MaterialTheme.colorScheme.secondary + SyncEventType.FILE_DELETED -> MaterialTheme.colorScheme.tertiary + SyncEventType.FILE_SKIPPED -> MaterialTheme.colorScheme.onSurfaceVariant + SyncEventType.CONFLICT_DETECTED -> MaterialTheme.colorScheme.tertiary + SyncEventType.CONFLICT_RESOLVED -> MaterialTheme.colorScheme.primary +} + +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())) } } diff --git a/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt index 6aeb8dc..8ec7e52 100644 --- a/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt @@ -1,14 +1,17 @@ package com.syncflow.ui.settings +import androidx.compose.foundation.BorderStroke 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.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.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.data.db.entities.CloudAccountEntity @@ -45,15 +48,18 @@ fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Cloud Accounts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + SectionHeader(title = "Cloud Accounts") + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { FilledTonalButton(onClick = onAddAccount) { Icon(Icons.Default.Add, null, Modifier.size(18.dp)) Spacer(Modifier.width(6.dp)) Text("Add Account") } } - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) } if (accounts.isEmpty()) { @@ -63,9 +69,22 @@ fun SettingsScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon(Icons.Default.CloudOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Text("No accounts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text("Add a cloud account to start syncing", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Icon( + Icons.Default.CloudOff, + null, + Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Text( + "No accounts yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Add a cloud account to start syncing", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) OutlinedButton(onClick = onAddAccount) { Icon(Icons.Default.Add, null, Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) @@ -80,40 +99,102 @@ fun SettingsScreen( } item { - Spacer(Modifier.height(16.dp)) - Text("Security", style = MaterialTheme.typography.titleMedium) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + Spacer(Modifier.height(8.dp)) + SectionHeader(title = "Security") + Spacer(Modifier.height(4.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), ) { - Column(modifier = Modifier.weight(1f)) { - Text("Biometric Lock", style = MaterialTheme.typography.bodyMedium) - Text( - "Require biometrics when returning to app", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Biometric Lock", style = MaterialTheme.typography.bodyMedium) + Text( + "Require biometrics when returning to app", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) }) } - Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) }) } } item { - Spacer(Modifier.height(16.dp)) - Text("About", style = MaterialTheme.typography.titleMedium) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Text("SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.", style = MaterialTheme.typography.bodySmall) - Text("Open source. No ads. No tracking.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + SectionHeader(title = "About") + Spacer(Modifier.height(4.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.", + style = MaterialTheme.typography.bodySmall, + ) + Text( + "Open source. No ads. No tracking.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } +@Composable +private fun SectionHeader(title: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } +} + @Composable private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(providerIcon(acct.providerType), null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary) + // Icon with primaryContainer background + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + providerIcon(acct.providerType), + null, + Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text(acct.displayName, style = MaterialTheme.typography.bodyMedium) @@ -138,22 +219,22 @@ private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) { private fun providerIcon(type: ProviderType) = when (type) { ProviderType.GOOGLE_DRIVE -> Icons.Default.Cloud - ProviderType.DROPBOX -> Icons.Default.CloudQueue - ProviderType.ONEDRIVE -> Icons.Default.CloudDone - ProviderType.WEBDAV -> Icons.Default.Storage - ProviderType.SFTP -> Icons.Default.Terminal - ProviderType.NEXTCLOUD -> Icons.Default.CloudCircle - ProviderType.OWNCLOUD -> Icons.Default.CloudCircle - ProviderType.SFTPGO -> Icons.Default.Storage + ProviderType.DROPBOX -> Icons.Default.CloudQueue + ProviderType.ONEDRIVE -> Icons.Default.CloudDone + ProviderType.WEBDAV -> Icons.Default.Storage + ProviderType.SFTP -> Icons.Default.Terminal + ProviderType.NEXTCLOUD -> Icons.Default.CloudCircle + ProviderType.OWNCLOUD -> Icons.Default.CloudCircle + ProviderType.SFTPGO -> Icons.Default.Storage } private fun friendlyProviderName(type: ProviderType) = when (type) { ProviderType.GOOGLE_DRIVE -> "Google Drive" - ProviderType.DROPBOX -> "Dropbox" - ProviderType.ONEDRIVE -> "OneDrive" - ProviderType.WEBDAV -> "WebDAV" - ProviderType.SFTP -> "SFTP" - ProviderType.NEXTCLOUD -> "Nextcloud" - ProviderType.OWNCLOUD -> "ownCloud" - ProviderType.SFTPGO -> "SFTPGo" + ProviderType.DROPBOX -> "Dropbox" + ProviderType.ONEDRIVE -> "OneDrive" + ProviderType.WEBDAV -> "WebDAV" + ProviderType.SFTP -> "SFTP" + ProviderType.NEXTCLOUD -> "Nextcloud" + ProviderType.OWNCLOUD -> "ownCloud" + ProviderType.SFTPGO -> "SFTPGo" } diff --git a/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt b/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt index 82f1f95..ac0f4ae 100644 --- a/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt @@ -2,8 +2,27 @@ package com.syncflow.ui.theme import androidx.compose.ui.graphics.Color -val SyncBlue = Color(0xFF2196F3) -val SyncGreen = Color(0xFF4CAF50) -val SyncOrange = Color(0xFFFF9800) -val SyncRed = Color(0xFFF44336) -val SyncPurple = Color(0xFF9C27B0) +// Primary — indigo +val Indigo600 = Color(0xFF4F46E5) +val Indigo900 = Color(0xFF312E81) +val Indigo100 = Color(0xFFE0E7FF) +val Indigo50 = Color(0xFFEEF2FF) + +// Secondary — teal +val Teal600 = Color(0xFF0D9488) +val Teal100 = Color(0xFFCCFBF1) + +// Tertiary — amber +val Amber500 = Color(0xFFF59E0B) +val Amber100 = Color(0xFFFEF3C7) + +// Neutrals +val Slate50 = Color(0xFFF8FAFC) +val Slate100 = Color(0xFFF1F5F9) +val Slate200 = Color(0xFFE2E8F0) +val Slate600 = Color(0xFF475569) +val Slate900 = Color(0xFF0F172A) + +// Semantic +val GreenSuccess = Color(0xFF16A34A) +val RedError = Color(0xFFDC2626) diff --git a/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt b/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt index d0b86ac..ce57cc5 100644 --- a/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt @@ -1,50 +1,77 @@ package com.syncflow.ui.theme import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat private val LightColors = lightColorScheme( - primary = SyncBlue, - onPrimary = androidx.compose.ui.graphics.Color.White, - secondary = SyncGreen, - tertiary = SyncPurple, + primary = Indigo600, + onPrimary = Color.White, + primaryContainer = Indigo100, + onPrimaryContainer = Indigo900, + secondary = Teal600, + onSecondary = Color.White, + secondaryContainer = Teal100, + tertiary = Amber500, + tertiaryContainer = Amber100, + background = Slate50, + surface = Color.White, + surfaceVariant = Slate100, + onSurfaceVariant = Slate600, + error = RedError, + errorContainer = Color(0xFFFEE2E2), + outline = Slate200, ) private val DarkColors = darkColorScheme( - primary = SyncBlue, - secondary = SyncGreen, - tertiary = SyncPurple, + primary = Color(0xFF818CF8), + onPrimary = Indigo900, + primaryContainer = Color(0xFF3730A3), + onPrimaryContainer = Indigo100, + secondary = Color(0xFF2DD4BF), + onSecondary = Color(0xFF003731), + secondaryContainer = Color(0xFF00504A), + tertiary = Amber500, + tertiaryContainer = Color(0xFF92400E), + background = Color(0xFF0F0F1A), + surface = Color(0xFF1A1A2E), + surfaceVariant = Color(0xFF252538), + onSurfaceVariant = Color(0xFF94A3B8), + error = Color(0xFFF87171), + errorContainer = Color(0xFF7F1D1D), + outline = Color(0xFF334155), +) + +private val AppTypography = Typography( + titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = (-0.5).sp), + titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = (-0.25).sp), + titleSmall = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, letterSpacing = 0.sp), + labelMedium = TextStyle(fontWeight = FontWeight.Medium, fontSize = 12.sp, letterSpacing = 0.1.sp), + labelSmall = TextStyle(fontWeight = FontWeight.Medium, fontSize = 11.sp, letterSpacing = 0.1.sp), ) @Composable fun SyncFlowTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val ctx = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) - } - darkTheme -> DarkColors - else -> LightColors - } + val colorScheme = if (darkTheme) DarkColors else LightColors val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = android.graphics.Color.TRANSPARENT WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } - MaterialTheme(colorScheme = colorScheme, typography = Typography(), content = content) + MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content) } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b95c9df --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..1142ffc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-hdpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.xml b/app/src/main/res/mipmap-mdpi/ic_launcher.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-mdpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-mdpi/ic_launcher.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml @@ -1,5 +1,5 @@ - - + + diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml index 0028949..d378acd 100644 --- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - + + diff --git a/version.properties b/version.properties index 54f9837..5cc92d9 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.12 -VERSION_CODE=13 +VERSION_NAME=1.0.13 +VERSION_CODE=14