feat: fix sync counters, polished activity rows, Files tab, new icon

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 22:05:28 +00:00
parent a7c5ed713a
commit 422e8f0f0f
11 changed files with 496 additions and 140 deletions
@@ -2,9 +2,13 @@ package com.syncflow.data.db
import androidx.room.* import androidx.room.*
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SyncFileStateDao { interface SyncFileStateDao {
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId ORDER BY relativePath ASC")
fun observeForPair(pairId: Long): Flow<List<SyncFileStateEntity>>
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId") @Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId")
suspend fun getForPair(pairId: Long): List<SyncFileStateEntity> suspend fun getForPair(pairId: Long): List<SyncFileStateEntity>
@@ -46,7 +46,7 @@ class SyncEngine @Inject constructor(
else -> SyncStatus.SUCCESS else -> SyncStatus.SUCCESS
} }
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts) 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 result
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Sync failed for pair ${pair.id}") Timber.e(e, "Sync failed for pair ${pair.id}")
@@ -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"
}
@@ -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<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
list.firstOrNull { it.id == id } ?: list.firstOrNull()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val files: StateFlow<List<SyncFileStateEntity>> = _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 }
}
@@ -6,16 +6,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.SyncEventType 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.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -143,31 +142,6 @@ private fun LogEntryRow(entry: LogEntry) {
} }
} }
@Composable
private fun SyncEventType.iconAndTint(): Pair<ImageVector, Color> = 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 { private fun java.time.LocalDate.toRelativeLabel(): String {
val today = java.time.LocalDate.now() val today = java.time.LocalDate.now()
return when { return when {
@@ -19,9 +19,11 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.ManageAccounts
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Sync 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.ManageAccounts
import androidx.compose.material.icons.outlined.NotificationsNone import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.Sync 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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.syncflow.R import com.syncflow.R
import com.syncflow.ui.files.FilesScreen
import com.syncflow.ui.home.HomeScreen import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.log.LogScreen import com.syncflow.ui.log.LogScreen
import com.syncflow.ui.settings.SettingsScreen import com.syncflow.ui.settings.SettingsScreen
@@ -45,7 +48,7 @@ fun MainShell(
onPairClick: (Long) -> Unit, onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit, onAddAccount: () -> Unit,
) { ) {
val pagerState = rememberPagerState(pageCount = { 3 }) val pagerState = rememberPagerState(pageCount = { 4 })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage val currentPage = pagerState.currentPage
@@ -102,7 +105,18 @@ fun MainShell(
onClick = { scope.launch { pagerState.animateScrollToPage(2) } }, onClick = { scope.launch { pagerState.animateScrollToPage(2) } },
icon = { icon = {
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, contentDescription = null,
) )
}, },
@@ -135,7 +149,8 @@ fun MainShell(
when (page) { when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick) 0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
1 -> LogScreen() 1 -> LogScreen()
2 -> SettingsScreen(onAddAccount = onAddAccount) 2 -> FilesScreen()
3 -> SettingsScreen(onAddAccount = onAddAccount)
} }
} }
} }
@@ -17,14 +17,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncStatus import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncEventRow
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -132,7 +130,7 @@ fun PairDetailScreen(
} }
} else { } else {
items(events, key = { it.id }) { event -> 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 { private fun String.toDisplayPath(): String {
val decoded = java.net.URLDecoder.decode(this, "UTF-8") val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded } return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
@@ -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<ImageVector, Color> = 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"
}
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient <gradient
android:type="linear" android:type="radial"
android:angle="135" android:gradientRadius="80%"
android:startColor="#2E1065" android:centerX="0.35"
android:centerColor="#6D28D9" android:centerY="0.3"
android:endColor="#1E40AF"/> android:startColor="#1C1124"
android:centerColor="#0E0A18"
android:endColor="#060408"/>
</shape> </shape>
@@ -6,65 +6,83 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Outer soft glow ring --> <!-- Soft ambient glow underneath everything -->
<path <path
android:pathData="M54,54m-44,0a44,44 0 1,0 88,0a44,44 0 1,0 -88,0" android:pathData="M54,54m-36,0a36,36 0 1,0 72,0a36,36 0 1,0 -72,0"
android:fillColor="#12FFFFFF"/> android:fillColor="#18FF6B6B"/>
<!-- Mid glow ring --> <!-- ═══ Arrow 1: pointing RIGHT (top) — electric coral ═══ -->
<path <!-- Shaft with rounded left cap -->
android:pathData="M54,54m-33,0a33,33 0 1,0 66,0a33,33 0 1,0 -66,0" <path android:pathData="M27,31 Q22,31 22,36 Q22,41 27,41 L64,41 L64,31 Z">
android:fillColor="#18FFFFFF"/>
<!-- Inner glow ring -->
<path
android:pathData="M54,54m-21,0a21,21 0 1,0 42,0a21,21 0 1,0 -42,0"
android:fillColor="#10FFFFFF"/>
<!-- Upload arrow (top-right) — neon cyan → sky blue -->
<path android:pathData="M54,18V4.5L36,22.5l18,18V27c14.895,0 27,12.105 27,27 0,4.545-1.125,8.865-3.15,12.6l6.57,6.57C87.93,67.635 90,61.065 90,54c0-19.89-16.11-36-36-36z">
<aapt:attr name="android:fillColor"> <aapt:attr name="android:fillColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="36" android:startY="4" android:startX="22" android:startY="36"
android:endX="90" android:endY="70" android:endX="64" android:endY="36"
android:startColor="#67E8F9" android:startColor="#FF6B6B"
android:endColor="#38BDF8"/> android:endColor="#FF9F6B"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M63,27 L63,45 L86,36 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="63" android:startY="36"
android:endX="86" android:endY="36"
android:startColor="#FF9F6B"
android:endColor="#FFB347"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Download arrow (bottom-left) — hot pink → coral --> <!-- ═══ Arrow 2: pointing LEFT (middle) — cool white/silver ═══ -->
<path android:pathData="M54,81c-14.895,0-27,-12.105-27,-27 0,-4.545 1.125,-8.865 3.15,-12.6L23.58,34.83C20.07,40.365 18,46.935 18,54c0,19.89 16.11,36 36,36v13.5l18,-18-18,-18v13.5z"> <!-- Shaft with rounded right cap -->
<path android:pathData="M44,50 L81,50 Q86,50 86,55 Q86,60 81,60 L44,60 Z">
<aapt:attr name="android:fillColor"> <aapt:attr name="android:fillColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="18" android:startY="35" android:startX="44" android:startY="55"
android:endX="72" android:endY="103" android:endX="86" android:endY="55"
android:startColor="#F472B6" android:startColor="#B0B8D0"
android:endColor="#FB923C"/> android:endColor="#E8EDF5"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M45,46 L45,64 L22,55 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="45" android:startY="55"
android:endX="22" android:endY="55"
android:startColor="#B0B8D0"
android:endColor="#8892A8"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Center glowing orb --> <!-- ═══ Arrow 3: pointing RIGHT (bottom) — electric teal ═══ -->
<path <!-- Shaft with rounded left cap -->
android:pathData="M54,54m-7,0a7,7 0 1,0 14,0a7,7 0 1,0 -14,0" <path android:pathData="M27,69 Q22,69 22,74 Q22,79 27,79 L64,79 L64,69 Z">
android:fillColor="#60FFFFFF"/> <aapt:attr name="android:fillColor">
<path <gradient android:type="linear"
android:pathData="M54,54m-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0" android:startX="22" android:startY="74"
android:fillColor="#FFFFFF"/> android:endX="64" android:endY="74"
android:startColor="#4DD0E1"
android:endColor="#26C6DA"/>
</aapt:attr>
</path>
<!-- Arrowhead -->
<path android:pathData="M63,65 L63,83 L86,74 Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear"
android:startX="63" android:startY="74"
android:endX="86" android:endY="74"
android:startColor="#26C6DA"
android:endColor="#00BCD4"/>
</aapt:attr>
</path>
<!-- Cardinal accent sparks --> <!-- Small glow dots at arrowhead tips for sparkle -->
<!-- Top — cyan --> <path android:pathData="M86,36m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0"
<path android:pathData="M54,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#22D3EE"/> android:fillColor="#FFFFB347"/>
<!-- Right — indigo --> <path android:pathData="M22,55m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0"
<path android:pathData="M95,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#818CF8"/> android:fillColor="#FF8892A8"/>
<!-- Bottom — pink --> <path android:pathData="M86,74m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0"
<path android:pathData="M54,95m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#F9A8D4"/> android:fillColor="#FF00BCD4"/>
<!-- Left — emerald -->
<path android:pathData="M13,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#6EE7B7"/>
<!-- Diagonal mini sparks (45°) -->
<path android:pathData="M85,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#A5F3FC"/>
<path android:pathData="M85,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#FDBA74"/>
<path android:pathData="M23,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#C084FC"/>
<path android:pathData="M23,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#86EFAC"/>
</vector> </vector>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.21 VERSION_NAME=1.0.22
VERSION_CODE=22 VERSION_CODE=23