From 422e8f0f0faac331f25f9ba443d173731d2fc728 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 24 May 2026 22:05:28 +0000 Subject: [PATCH] feat: fix sync counters, polished activity rows, Files tab, new icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SYNC_COMPLETED showing ↑0 ↓0 ✗0 when only deletions occurred: add ✕N for deleted files to the summary message (↑N ↓N ✕N ✗N format) - Fix PairDetail Activity section showing raw "SYNC_STARTED" enum names and "remote" as a plain subtitle: replace dot-based EventRow with the same polished icon-bubble rows as the global Log tab - Extract shared SyncEventRow composable + iconAndTint/label helpers to ui/shared/SyncEventRow.kt; both LogScreen and PairDetailScreen now use it - Add Files tab (4th tab between Log and Accounts): folder browser showing all synced files per pair, grouped by subdirectory, with file-type icons, size, last-synced date, and a summary header (N files, total size) - Add SyncFileStateDao.observeForPair() reactive Flow query for Files tab - Completely redesign app icon: near-black radial gradient background with three bold directional arrows in an S-pattern (coral → silver → teal), each with gradient fills and tip-glow dots — entirely different from the typical circular sync-arrow style - Bump version to 1.0.22 (build 23) Co-Authored-By: Claude Sonnet 4.6 --- .../com/syncflow/data/db/SyncFileStateDao.kt | 4 + .../com/syncflow/domain/sync/SyncEngine.kt | 2 +- .../com/syncflow/ui/files/FilesScreen.kt | 254 ++++++++++++++++++ .../com/syncflow/ui/files/FilesViewModel.kt | 39 +++ .../kotlin/com/syncflow/ui/log/LogScreen.kt | 30 +-- .../kotlin/com/syncflow/ui/main/MainShell.kt | 21 +- .../ui/pairdetail/PairDetailScreen.kt | 56 +--- .../com/syncflow/ui/shared/SyncEventRow.kt | 102 +++++++ .../res/drawable/ic_launcher_background.xml | 12 +- .../res/drawable/ic_launcher_foreground.xml | 112 ++++---- version.properties | 4 +- 11 files changed, 496 insertions(+), 140 deletions(-) create mode 100644 app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt create mode 100644 app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt create mode 100644 app/src/main/kotlin/com/syncflow/ui/shared/SyncEventRow.kt diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt index dfe11cd..0243845 100644 --- a/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt @@ -2,9 +2,13 @@ package com.syncflow.data.db import androidx.room.* import com.syncflow.data.db.entities.SyncFileStateEntity +import kotlinx.coroutines.flow.Flow @Dao interface SyncFileStateDao { + @Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId ORDER BY relativePath ASC") + fun observeForPair(pairId: Long): Flow> + @Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId") suspend fun getForPair(pairId: Long): List diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt index 33ac95b..788cbfb 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -46,7 +46,7 @@ class SyncEngine @Inject constructor( else -> SyncStatus.SUCCESS } syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts) - logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "↑${result.uploaded} ↓${result.downloaded} ✗${result.failedFiles}", result.bytesTransferred) + logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "↑${result.uploaded} ↓${result.downloaded} ✕${result.deleted} ✗${result.failedFiles}", result.bytesTransferred) result } catch (e: Exception) { Timber.e(e, "Sync failed for pair ${pair.id}") diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt new file mode 100644 index 0000000..0a84541 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt @@ -0,0 +1,254 @@ +package com.syncflow.ui.files + +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.syncflow.data.db.entities.SyncFileStateEntity +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilesScreen( + modifier: Modifier = Modifier, + vm: FilesViewModel = hiltViewModel(), +) { + val pairs by vm.pairs.collectAsState() + val selectedPair by vm.selectedPair.collectAsState() + val files by vm.files.collectAsState() + + Column(modifier = modifier.fillMaxSize()) { + // Pair selector chips + if (pairs.size > 1) { + ScrollableTabRow( + selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), + edgePadding = 16.dp, + containerColor = MaterialTheme.colorScheme.surface, + divider = {}, + ) { + pairs.forEach { pair -> + Tab( + selected = pair.id == selectedPair?.id, + onClick = { vm.selectPair(pair.id) }, + text = { + Text( + pair.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + HorizontalDivider() + } + + if (pairs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + ) + Text("No sync pairs yet", style = MaterialTheme.typography.titleMedium) + Text( + "Create a sync pair to browse its files", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + ) + Text("No synced files yet", style = MaterialTheme.typography.titleMedium) + Text( + "Run a sync to populate this view", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + // Summary row + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${files.size} file${if (files.size != 1) "s" else ""}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val totalBytes = files.sumOf { it.localSizeBytes } + Text( + totalBytes.toDisplaySize(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + // Group by top-level directory + val grouped = files.groupBy { f -> + val slashIdx = f.relativePath.indexOf('/') + if (slashIdx < 0) "" else f.relativePath.substring(0, slashIdx) + } + grouped.forEach { (dir, dirFiles) -> + if (dir.isNotEmpty()) { + item(key = "dir_$dir") { + Row( + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(6.dp)) + Text( + dir, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> + FileRow(file, isInSubDir = dir.isNotEmpty()) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), + modifier = Modifier.padding(start = if (dir.isNotEmpty()) 38.dp else 16.dp), + ) + } + } + item { Spacer(Modifier.height(80.dp)) } + } + } + } +} + +@Composable +private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean) { + val name = file.relativePath.substringAfterLast('/') + val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) + val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = if (isInSubDir) 22.dp else 0.dp, + top = 10.dp, + bottom = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + fileIcon(name), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + name, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "Synced $syncedAt · ${file.localSizeBytes.toDisplaySize()}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Sync status indicator + Icon( + Icons.Default.CheckCircle, + contentDescription = "Synced", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + ) + } +} + +private fun fileIcon(name: String) = when { + name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf + name.endsWith(".jpg", ignoreCase = true) || + name.endsWith(".jpeg", ignoreCase = true) || + name.endsWith(".png", ignoreCase = true) || + name.endsWith(".gif", ignoreCase = true) || + name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image + name.endsWith(".mp4", ignoreCase = true) || + name.endsWith(".mkv", ignoreCase = true) || + name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile + name.endsWith(".mp3", ignoreCase = true) || + name.endsWith(".m4a", ignoreCase = true) || + name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile + name.endsWith(".zip", ignoreCase = true) || + name.endsWith(".tar", ignoreCase = true) || + name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip + name.endsWith(".txt", ignoreCase = true) || + name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet + else -> Icons.Default.InsertDriveFile +} + +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" +} diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt new file mode 100644 index 0000000..da1b5f9 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt @@ -0,0 +1,39 @@ +package com.syncflow.ui.files + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.syncflow.data.db.SyncFileStateDao +import com.syncflow.data.db.SyncPairDao +import com.syncflow.data.db.entities.SyncFileStateEntity +import com.syncflow.data.db.entities.SyncPairEntity +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class FilesViewModel @Inject constructor( + syncPairDao: SyncPairDao, + private val fileStateDao: SyncFileStateDao, +) : ViewModel() { + + val pairs: StateFlow> = syncPairDao.observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _selectedPairId = MutableStateFlow(null) + + val selectedPair: StateFlow = combine(_selectedPairId, pairs) { id, list -> + list.firstOrNull { it.id == id } ?: list.firstOrNull() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val files: StateFlow> = _selectedPairId + .flatMapLatest { id -> + if (id == null) pairs.map { it.firstOrNull()?.id }.filterNotNull() + .flatMapLatest { fileStateDao.observeForPair(it) } + else fileStateDao.observeForPair(id) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun selectPair(id: Long) { _selectedPairId.value = id } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt b/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt index ea31ec1..8b767f7 100644 --- a/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt @@ -6,16 +6,15 @@ 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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.domain.model.SyncEventType +import com.syncflow.ui.shared.iconAndTint +import com.syncflow.ui.shared.label import java.time.Duration import java.time.Instant import java.time.ZoneId @@ -143,31 +142,6 @@ private fun LogEntryRow(entry: LogEntry) { } } -@Composable -private fun SyncEventType.iconAndTint(): Pair = when (this) { - SyncEventType.SYNC_STARTED -> Icons.Default.Sync to MaterialTheme.colorScheme.secondary - SyncEventType.SYNC_COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary - SyncEventType.SYNC_FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error - SyncEventType.FILE_UPLOADED -> Icons.Default.CloudUpload to MaterialTheme.colorScheme.secondary - SyncEventType.FILE_DOWNLOADED -> Icons.Default.CloudDownload to MaterialTheme.colorScheme.secondary - SyncEventType.FILE_DELETED -> Icons.Default.DeleteForever to MaterialTheme.colorScheme.tertiary - SyncEventType.FILE_SKIPPED -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant - SyncEventType.CONFLICT_DETECTED -> Icons.Default.Warning to MaterialTheme.colorScheme.tertiary - SyncEventType.CONFLICT_RESOLVED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary -} - -private fun SyncEventType.label(): String = when (this) { - SyncEventType.SYNC_STARTED -> "Sync started" - SyncEventType.SYNC_COMPLETED -> "Sync completed" - SyncEventType.SYNC_FAILED -> "Sync failed" - SyncEventType.FILE_UPLOADED -> "File uploaded" - SyncEventType.FILE_DOWNLOADED -> "File downloaded" - SyncEventType.FILE_DELETED -> "File deleted" - SyncEventType.FILE_SKIPPED -> "File skipped" - SyncEventType.CONFLICT_DETECTED -> "Conflict detected" - SyncEventType.CONFLICT_RESOLVED -> "Conflict resolved" -} - private fun java.time.LocalDate.toRelativeLabel(): String { val today = java.time.LocalDate.now() return when { 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 f9954e6..a546c48 100644 --- a/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt +++ b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt @@ -19,9 +19,11 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.material.icons.outlined.NotificationsNone import androidx.compose.material.icons.outlined.Sync @@ -33,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.syncflow.R +import com.syncflow.ui.files.FilesScreen import com.syncflow.ui.home.HomeScreen import com.syncflow.ui.log.LogScreen import com.syncflow.ui.settings.SettingsScreen @@ -45,7 +48,7 @@ fun MainShell( onPairClick: (Long) -> Unit, onAddAccount: () -> Unit, ) { - val pagerState = rememberPagerState(pageCount = { 3 }) + val pagerState = rememberPagerState(pageCount = { 4 }) val scope = rememberCoroutineScope() val currentPage = pagerState.currentPage @@ -102,7 +105,18 @@ fun MainShell( onClick = { scope.launch { pagerState.animateScrollToPage(2) } }, icon = { Icon( - if (currentPage == 2) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts, + if (currentPage == 2) Icons.Filled.Folder else Icons.Outlined.FolderOpen, + contentDescription = null, + ) + }, + label = { Text("Files") }, + ) + NavigationBarItem( + selected = currentPage == 3, + onClick = { scope.launch { pagerState.animateScrollToPage(3) } }, + icon = { + Icon( + if (currentPage == 3) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts, contentDescription = null, ) }, @@ -135,7 +149,8 @@ fun MainShell( when (page) { 0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick) 1 -> LogScreen() - 2 -> SettingsScreen(onAddAccount = onAddAccount) + 2 -> FilesScreen() + 3 -> SettingsScreen(onAddAccount = onAddAccount) } } } 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 61c3da0..9ac7a1c 100644 --- a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt @@ -17,14 +17,12 @@ 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.graphics.graphicsLayer 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 com.syncflow.ui.shared.SyncEventRow import java.time.Duration import java.time.Instant import java.time.ZoneId @@ -132,7 +130,7 @@ fun PairDetailScreen( } } else { items(events, key = { it.id }) { event -> - EventRow(event) + SyncEventRow(event, showDivider = event != events.last()) } } } @@ -231,56 +229,6 @@ private fun InfoRow( } } -@Composable -private fun EventRow(event: SyncEventEntity) { - val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - val zone = ZoneId.systemDefault() - val dotColor = eventColor(event.eventType) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // 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, - ) - event.message?.takeIf { event.filePath != null }?.let { - 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, - ) - } -} - -@Composable -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 } diff --git a/app/src/main/kotlin/com/syncflow/ui/shared/SyncEventRow.kt b/app/src/main/kotlin/com/syncflow/ui/shared/SyncEventRow.kt new file mode 100644 index 0000000..65ceafb --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/shared/SyncEventRow.kt @@ -0,0 +1,102 @@ +package com.syncflow.ui.shared + +import androidx.compose.foundation.layout.* +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.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.syncflow.data.db.entities.SyncEventEntity +import com.syncflow.domain.model.SyncEventType +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun SyncEventRow(event: SyncEventEntity, showDivider: Boolean = true) { + val (icon, tint) = event.eventType.iconAndTint() + val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + val timeStr = timeFmt.format(event.timestamp.atZone(ZoneId.systemDefault())) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = tint.copy(alpha = 0.12f), + modifier = Modifier.size(36.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + event.eventType.label(), + style = MaterialTheme.typography.labelMedium, + ) + Text( + timeStr, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val detail = event.filePath ?: event.message + if (detail != null) { + Spacer(Modifier.height(2.dp)) + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + modifier = Modifier.padding(start = 48.dp), + ) + } +} + +@Composable +fun SyncEventType.iconAndTint(): Pair = when (this) { + SyncEventType.SYNC_STARTED -> Icons.Default.Sync to MaterialTheme.colorScheme.secondary + SyncEventType.SYNC_COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary + SyncEventType.SYNC_FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error + SyncEventType.FILE_UPLOADED -> Icons.Default.CloudUpload to MaterialTheme.colorScheme.secondary + SyncEventType.FILE_DOWNLOADED -> Icons.Default.CloudDownload to MaterialTheme.colorScheme.secondary + SyncEventType.FILE_DELETED -> Icons.Default.DeleteForever to MaterialTheme.colorScheme.tertiary + SyncEventType.FILE_SKIPPED -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant + SyncEventType.CONFLICT_DETECTED -> Icons.Default.Warning to MaterialTheme.colorScheme.tertiary + SyncEventType.CONFLICT_RESOLVED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary +} + +fun SyncEventType.label(): String = when (this) { + SyncEventType.SYNC_STARTED -> "Sync started" + SyncEventType.SYNC_COMPLETED -> "Sync completed" + SyncEventType.SYNC_FAILED -> "Sync failed" + SyncEventType.FILE_UPLOADED -> "File uploaded" + SyncEventType.FILE_DOWNLOADED -> "File downloaded" + SyncEventType.FILE_DELETED -> "File deleted" + SyncEventType.FILE_SKIPPED -> "File skipped" + SyncEventType.CONFLICT_DETECTED -> "Conflict detected" + SyncEventType.CONFLICT_RESOLVED -> "Conflict resolved" +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 77791c0..28aa6d2 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,9 +1,11 @@ + android:type="radial" + android:gradientRadius="80%" + android:centerX="0.35" + android:centerY="0.3" + android:startColor="#1C1124" + android:centerColor="#0E0A18" + android:endColor="#060408"/> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index a5ac1a9..3e2a027 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -6,65 +6,83 @@ android:viewportWidth="108" android:viewportHeight="108"> - + + android:pathData="M54,54m-36,0a36,36 0 1,0 72,0a36,36 0 1,0 -72,0" + android:fillColor="#18FF6B6B"/> - - - - - - - - + + + + android:startX="22" android:startY="36" + android:endX="64" android:endY="36" + android:startColor="#FF6B6B" + android:endColor="#FF9F6B"/> + + + + + + - - + + + + android:startX="44" android:startY="55" + android:endX="86" android:endY="55" + android:startColor="#B0B8D0" + android:endColor="#E8EDF5"/> + + + + + + - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + diff --git a/version.properties b/version.properties index 70d9618..e5d07e3 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.21 -VERSION_CODE=22 +VERSION_NAME=1.0.22 +VERSION_CODE=23