From 8fdd22bc9867ab7860faba492c68c1cfce2164ba Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Mon, 25 May 2026 02:22:43 +0000 Subject: [PATCH] v1.0.25: multi-select files, unified notification, dark theme, icon redesign - FilesScreen: long-press enters selection mode, bulk share/delete toolbar, BackHandler - FilesViewModel: ShareMultiple action, isSelectionMode/selectedCount state, download-to-cache for open/share - FileWatchService: recursive FileObserver per subdirectory; unified notification updated with sync result via WorkManager flow observation - SyncWorker: silent flag suppresses notifications when triggered by watcher; emits KEY_RESULT_SUMMARY output data - Passbolt-inspired dark theme (Red700/Red500 primary, near-black surface) - App icon: circular AutoSync-style sync arrows (cyan gradient, deep navy background) Co-Authored-By: Claude Sonnet 4.6 --- .../com/syncflow/ui/files/FilesScreen.kt | 326 ++++++++++++------ .../com/syncflow/ui/files/FilesViewModel.kt | 46 +++ .../kotlin/com/syncflow/ui/theme/Color.kt | 35 +- .../kotlin/com/syncflow/ui/theme/Theme.kt | 52 +-- .../com/syncflow/worker/FileWatchService.kt | 39 ++- .../kotlin/com/syncflow/worker/SyncWorker.kt | 32 +- .../res/drawable/ic_launcher_background.xml | 9 +- .../res/drawable/ic_launcher_foreground.xml | 64 ++-- version.properties | 4 +- 9 files changed, 392 insertions(+), 215 deletions(-) diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt index b570f86..cd8b671 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt @@ -1,7 +1,11 @@ package com.syncflow.ui.files import android.content.Intent +import android.net.Uri import android.webkit.MimeTypeMap +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -20,11 +24,12 @@ import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.data.db.entities.SyncFileStateEntity import kotlinx.coroutines.launch +import java.io.File import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun FilesScreen( modifier: Modifier = Modifier, @@ -34,9 +39,14 @@ fun FilesScreen( val selectedPair by vm.selectedPair.collectAsState() val files by vm.files.collectAsState() val isDownloading by vm.isDownloading.collectAsState() + val isSelectionMode by vm.isSelectionMode.collectAsState() + val selectedCount by vm.selectedCount.collectAsState() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + var showDeleteSelectedDialog by remember { mutableStateOf(false) } + + BackHandler(enabled = isSelectionMode) { vm.clearSelection() } LaunchedEffect(Unit) { vm.fileAction.collect { action -> @@ -78,6 +88,25 @@ fun FilesScreen( snackbarHostState.showSnackbar("Cannot share file: ${e.message}") } } + is FileAction.ShareMultiple -> { + try { + val uris = action.files.map { file -> + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + } + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "*/*" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity( + Intent.createChooser(intent, "Share files").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } catch (e: Exception) { + snackbarHostState.showSnackbar("Cannot share: ${e.message}") + } + } is FileAction.Error -> scope.launch { snackbarHostState.showSnackbar(action.message) } @@ -85,9 +114,62 @@ fun FilesScreen( } } + if (showDeleteSelectedDialog) { + AlertDialog( + onDismissRequest = { showDeleteSelectedDialog = false }, + icon = { Icon(Icons.Default.Delete, contentDescription = null) }, + title = { Text("Delete $selectedCount file${if (selectedCount != 1) "s" else ""}?") }, + text = { Text("Selected files will be removed from this device.") }, + confirmButton = { + TextButton(onClick = { + vm.deleteSelected() + showDeleteSelectedDialog = false + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteSelectedDialog = false }) { Text("Cancel") } + }, + ) + } + Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { - if (pairs.size > 1) { + // Selection toolbar + if (isSelectionMode) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + tonalElevation = 3.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { vm.clearSelection() }) { + Icon(Icons.Default.Close, contentDescription = "Clear selection") + } + Text( + "$selectedCount selected", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { vm.shareSelected() }) { + Icon(Icons.Default.Share, contentDescription = "Share selected") + } + IconButton(onClick = { showDeleteSelectedDialog = true }) { + Icon( + Icons.Default.Delete, contentDescription = "Delete selected", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + HorizontalDivider() + } + + // Pair tabs + if (pairs.size > 1 && !isSelectionMode) { ScrollableTabRow( selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), edgePadding = 16.dp, @@ -117,24 +199,26 @@ fun FilesScreen( subtitle = "Run a sync to populate this view", ) else -> { - 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, - ) - Text( - files.sumOf { it.localSizeBytes }.toDisplaySize(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (!isSelectionMode) { + 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, + ) + Text( + files.sumOf { it.localSizeBytes }.toDisplaySize(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -147,7 +231,7 @@ fun FilesScreen( if (idx < 0) "" else f.relativePath.substring(0, idx) } grouped.forEach { (dir, dirFiles) -> - if (dir.isNotEmpty()) { + if (dir.isNotEmpty() && !isSelectionMode) { item(key = "dir_$dir") { Row( modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), @@ -168,12 +252,17 @@ fun FilesScreen( } } items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> - FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm) + val selected = vm.isSelected(file) + FileRow( + file = file, + isInSubDir = dir.isNotEmpty() && !isSelectionMode, + isSelectionMode = isSelectionMode, + isSelected = selected, + vm = vm, + ) HorizontalDivider( color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), - modifier = Modifier.padding( - start = if (dir.isNotEmpty()) 38.dp else 16.dp - ), + modifier = Modifier.padding(start = if (dir.isNotEmpty() && !isSelectionMode) 38.dp else 16.dp), ) } } @@ -183,6 +272,7 @@ fun FilesScreen( } } + // Download progress if (isDownloading) { Box( modifier = Modifier @@ -237,8 +327,15 @@ private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String) } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesViewModel) { +private fun FileRow( + file: SyncFileStateEntity, + isInSubDir: Boolean, + isSelectionMode: Boolean, + isSelected: Boolean, + vm: FilesViewModel, +) { val name = file.relativePath.substringAfterLast('/') val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) } @@ -250,10 +347,7 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie if (showRenameDialog) { RenameDialog( currentName = name, - onConfirm = { newName -> - vm.renameFile(file, newName) - showRenameDialog = false - }, + onConfirm = { newName -> vm.renameFile(file, newName); showRenameDialog = false }, onDismiss = { showRenameDialog = false }, ) } @@ -275,89 +369,104 @@ private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesVie ) } - Row( + Surface( + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() - .padding( + .combinedClickable( + onClick = { + if (isSelectionMode) vm.toggleSelection(file) else menuExpanded = true + }, + onLongClick = { vm.toggleSelection(file) }, + ), + ) { + Row( + modifier = Modifier.padding( start = if (isInSubDir) 22.dp else 0.dp, top = 10.dp, bottom = 10.dp, + end = 0.dp, ), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(32.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box(contentAlignment = Alignment.Center) { - Icon( - fileIcon(name), contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, + if (isSelectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { vm.toggleSelection(file) }, + modifier = Modifier.padding(horizontal = 4.dp), ) - } - } - 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, - ) - } - Box { - IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) { - Icon( - Icons.Default.MoreVert, contentDescription = "File options", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { - DropdownMenuItem( - text = { Text("Open") }, - leadingIcon = { Icon(Icons.Default.OpenInNew, contentDescription = null) }, - onClick = { menuExpanded = false; vm.openFile(file) }, - ) - DropdownMenuItem( - text = { Text("Share") }, - leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, - onClick = { menuExpanded = false; vm.shareFile(file) }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Rename") }, - leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, - onClick = { menuExpanded = false; showRenameDialog = true }, - ) - DropdownMenuItem( - text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, - leadingIcon = { + } else { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { Icon( - Icons.Default.Delete, contentDescription = null, - tint = MaterialTheme.colorScheme.error, + fileIcon(name), contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, ) - }, - onClick = { menuExpanded = false; showDeleteDialog = true }, + } + } + 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, + ) + } + if (!isSelectionMode) { + Box { + IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Default.MoreVert, contentDescription = "File options", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text("Open") }, + leadingIcon = { Icon(Icons.Default.OpenInNew, null) }, + onClick = { menuExpanded = false; vm.openFile(file) }, + ) + DropdownMenuItem( + text = { Text("Share") }, + leadingIcon = { Icon(Icons.Default.Share, null) }, + onClick = { menuExpanded = false; vm.shareFile(file) }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Default.Edit, null) }, + onClick = { menuExpanded = false; showRenameDialog = true }, + ) + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) + }, + onClick = { menuExpanded = false; showDeleteDialog = true }, + ) + } + } } } } } @Composable -private fun RenameDialog( - currentName: String, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { +private fun RenameDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) { var newName by remember { mutableStateOf(currentName) } AlertDialog( onDismissRequest = onDismiss, @@ -376,15 +485,12 @@ private fun RenameDialog( TextButton( onClick = { val trimmed = newName.trim() - if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) - else onDismiss() + if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) else onDismiss() }, enabled = newName.isNotBlank(), ) { Text("Rename") } }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } @@ -395,22 +501,16 @@ private fun String.mimeType(): String { 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(".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(".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(".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(".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 + name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet else -> Icons.Default.InsertDriveFile } diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt index eb9aa53..5cd172c 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt @@ -14,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.update import timber.log.Timber import java.io.File import javax.inject.Inject @@ -21,6 +22,7 @@ import javax.inject.Inject sealed class FileAction { data class Open(val file: File) : FileAction() data class Share(val file: File) : FileAction() + data class ShareMultiple(val files: List) : FileAction() data class Error(val message: String) : FileAction() } @@ -57,6 +59,12 @@ class FilesViewModel @Inject constructor( private val _isDownloading = MutableStateFlow(false) val isDownloading: StateFlow = _isDownloading + private val _selectedKeys = MutableStateFlow>(emptySet()) + val isSelectionMode: StateFlow = _selectedKeys.map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val selectedCount: StateFlow = _selectedKeys.map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + fun selectPair(id: Long) { _selectedPairId.value = id } fun openFile(file: SyncFileStateEntity) { @@ -108,6 +116,44 @@ class FilesViewModel @Inject constructor( } } + fun isSelected(file: SyncFileStateEntity): Boolean = fileKey(file) in _selectedKeys.value + + fun toggleSelection(file: SyncFileStateEntity) { + val key = fileKey(file) + _selectedKeys.update { if (key in it) it - key else it + key } + } + + fun clearSelection() { _selectedKeys.value = emptySet() } + + fun deleteSelected() { + viewModelScope.launch { + val toDelete = files.value.filter { isSelected(it) } + toDelete.forEach { file -> + try { + resolveFile(file, emitErrorIfMissing = false)?.delete() + fileStateDao.delete(file.syncPairId, file.relativePath) + } catch (e: Exception) { + Timber.e(e, "Bulk delete failed: ${file.relativePath}") + } + } + clearSelection() + } + } + + fun shareSelected() { + viewModelScope.launch { + val toShare = files.value.filter { isSelected(it) } + val resolved = toShare.mapNotNull { resolveFile(it, emitErrorIfMissing = false) } + if (resolved.isEmpty()) { + _fileAction.emit(FileAction.Error("No local files available to share")) + return@launch + } + _fileAction.emit(FileAction.ShareMultiple(resolved)) + } + } + + private fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}" + // ── Download-then-open/share ────────────────────────────────────────────── private fun downloadAndOpen(file: SyncFileStateEntity) { 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 ac0f4ae..192c63c 100644 --- a/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt @@ -2,27 +2,28 @@ package com.syncflow.ui.theme import androidx.compose.ui.graphics.Color -// Primary — indigo -val Indigo600 = Color(0xFF4F46E5) -val Indigo900 = Color(0xFF312E81) -val Indigo100 = Color(0xFFE0E7FF) -val Indigo50 = Color(0xFFEEF2FF) +// Primary — deep red (Passbolt-inspired) +val Red900 = Color(0xFF7F0000) +val Red700 = Color(0xFFB71C1C) +val Red500 = Color(0xFFEF5350) +val Red100 = Color(0xFFFFCDD2) +val Red50 = Color(0xFFFFEBEE) -// Secondary — teal -val Teal600 = Color(0xFF0D9488) -val Teal100 = Color(0xFFCCFBF1) +// Secondary — deep orange +val Orange700 = Color(0xFFE64A19) +val Orange100 = Color(0xFFFBE9E7) // Tertiary — amber -val Amber500 = Color(0xFFF59E0B) -val Amber100 = Color(0xFFFEF3C7) +val Amber500 = Color(0xFFFFB300) +val Amber100 = Color(0xFFFFF8E1) // Neutrals -val Slate50 = Color(0xFFF8FAFC) -val Slate100 = Color(0xFFF1F5F9) -val Slate200 = Color(0xFFE2E8F0) -val Slate600 = Color(0xFF475569) -val Slate900 = Color(0xFF0F172A) +val Gray50 = Color(0xFFF8F9FA) +val Gray100 = Color(0xFFF3F4F6) +val Gray200 = Color(0xFFE5E7EB) +val Gray600 = Color(0xFF6B7280) +val Gray900 = Color(0xFF111827) // Semantic -val GreenSuccess = Color(0xFF16A34A) -val RedError = Color(0xFFDC2626) +val GreenSuccess = Color(0xFF2E7D32) +val RedError = Color(0xFFEF5350) 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 ce57cc5..cc1ac6f 100644 --- a/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt @@ -13,41 +13,43 @@ import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat private val LightColors = lightColorScheme( - primary = Indigo600, + primary = Red700, onPrimary = Color.White, - primaryContainer = Indigo100, - onPrimaryContainer = Indigo900, - secondary = Teal600, + primaryContainer = Red50, + onPrimaryContainer = Red900, + secondary = Orange700, onSecondary = Color.White, - secondaryContainer = Teal100, + secondaryContainer = Orange100, tertiary = Amber500, tertiaryContainer = Amber100, - background = Slate50, + background = Gray50, surface = Color.White, - surfaceVariant = Slate100, - onSurfaceVariant = Slate600, + surfaceVariant = Gray100, + onSurface = Gray900, + onSurfaceVariant = Gray600, error = RedError, - errorContainer = Color(0xFFFEE2E2), - outline = Slate200, + errorContainer = Red50, + outline = Gray200, ) private val DarkColors = darkColorScheme( - primary = Color(0xFF818CF8), - onPrimary = Indigo900, - primaryContainer = Color(0xFF3730A3), - onPrimaryContainer = Indigo100, - secondary = Color(0xFF2DD4BF), - onSecondary = Color(0xFF003731), - secondaryContainer = Color(0xFF00504A), + primary = Red500, + onPrimary = Color.White, + primaryContainer = Red900, + onPrimaryContainer = Red100, + secondary = Color(0xFFFF7043), + onSecondary = Color.White, + secondaryContainer = Color(0xFF4E1500), 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), + tertiaryContainer = Color(0xFF3E2700), + background = Color(0xFF0F0F0F), + surface = Color(0xFF1C1C1C), + surfaceVariant = Color(0xFF2A2A2A), + onSurface = Color(0xFFEAEAEA), + onSurfaceVariant = Color(0xFF9E9E9E), + error = Color(0xFFFF5252), + errorContainer = Color(0xFF5C0000), + outline = Color(0xFF3D3D3D), ) private val AppTypography = Typography( diff --git a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt index f13299a..922afc8 100644 --- a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt +++ b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt @@ -13,7 +13,9 @@ import android.os.Looper import android.provider.DocumentsContract import androidx.core.app.NotificationCompat import androidx.work.ExistingWorkPolicy +import androidx.work.WorkInfo import androidx.work.WorkManager +import kotlinx.coroutines.flow.first import com.syncflow.MainActivity import com.syncflow.R import com.syncflow.data.db.SyncFileStateDao @@ -214,9 +216,34 @@ class FileWatchService : Service() { val pair = syncPairDao.getById(pairId) if (pair == null || !pair.isEnabled) return@launch Timber.d("FileWatchService: triggering sync for pair $pairId after debounce") - val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly) + + val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true) WorkManager.getInstance(applicationContext) .enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req) + + // Update notification while sync is in progress + updateNotificationDynamic("Syncing: ${pair.name}…") + + // Wait for completion and show result in the persistent notification + scope.launch { + try { + val info = WorkManager.getInstance(applicationContext) + .getWorkInfoByIdFlow(req.id) + .first { it?.state?.isFinished == true } + val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY) + val watchCount = fileObservers.keys.size + contentObservers.size + val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}" + if (info?.state == WorkInfo.State.SUCCEEDED && summary != null) { + updateNotificationDynamic("${pair.name}: $summary — $watching") + } else { + updateNotificationDynamic("$watching") + } + delay(12_000) + updateNotificationDynamic(null) // revert to default watching text + } catch (_: Exception) { + updateNotificationDynamic(null) + } + } } } @@ -241,7 +268,7 @@ class FileWatchService : Service() { } } - private fun buildNotification(count: Int): Notification { + private fun buildNotification(count: Int, overrideText: String? = null): Notification { val tapIntent = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, @@ -250,7 +277,7 @@ class FileWatchService : Service() { return NotificationCompat.Builder(this, CHANNEL_WATCH) .setContentTitle("SyncFlow") .setContentText( - if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes" + overrideText ?: if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes" else "Starting file watcher…" ) .setSmallIcon(R.drawable.ic_sync) @@ -264,4 +291,10 @@ class FileWatchService : Service() { val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIFICATION_ID, buildNotification(count)) } + + private fun updateNotificationDynamic(overrideText: String?) { + val count = fileObservers.keys.size + contentObservers.size + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, buildNotification(count, overrideText)) + } } diff --git a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt index da7a5b7..70278ff 100644 --- a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt +++ b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt @@ -39,6 +39,8 @@ class SyncWorker @AssistedInject constructor( val pair = syncPairDao.getById(pairId) ?: return Result.failure() val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure() + val silent = inputData.getBoolean(KEY_SILENT, false) + ensureChannels() setForeground(buildForegroundInfo(pair.name, "Syncing…")) @@ -47,7 +49,15 @@ class SyncWorker @AssistedInject constructor( val provider = providerFactory.create(account) val result = syncEngine.sync(domainPair, provider) - if (result.error != null && pair.notifyOnError) { + val lines = buildList { + if (result.uploaded > 0) add("↑${result.uploaded}") + if (result.downloaded > 0) add("↓${result.downloaded}") + if (result.deleted > 0) add("🗑${result.deleted}") + if (result.conflicts > 0) add("⚠${result.conflicts}") + } + val resultSummary = if (lines.isEmpty()) "Up to date" else lines.joinToString(" ") + + if (!silent && result.error != null && pair.notifyOnError) { notify( id = pairId.toInt() + RESULT_ID_OFFSET, channelId = CHANNEL_ALERTS, @@ -55,28 +65,28 @@ class SyncWorker @AssistedInject constructor( text = result.error.message ?: "Unknown error", priority = NotificationCompat.PRIORITY_DEFAULT, ) - } else if (pair.notifyOnComplete && result.error == null) { - val lines = buildList { + } else if (!silent && pair.notifyOnComplete && result.error == null) { + val fullLines = buildList { if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}") if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}") if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}") if (result.conflicts > 0) add("⚠ ${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}") } - val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n") + val summary = if (fullLines.isEmpty()) "Up to date — nothing to sync" else fullLines.joinToString("\n") notify( id = pairId.toInt() + RESULT_ID_OFFSET, channelId = CHANNEL_COMPLETE, - title = "${pair.name} — Changes synced", - text = if (lines.isEmpty()) summary else lines.first(), + title = "${pair.name} — Synced", + text = if (fullLines.isEmpty()) summary else fullLines.first(), bigText = summary, priority = NotificationCompat.PRIORITY_LOW, ) } - Result.success() + Result.success(workDataOf(KEY_RESULT_SUMMARY to resultSummary)) } catch (e: Exception) { Timber.e(e, "SyncWorker failed for pair $pairId") - if (pair.notifyOnError) { + if (!silent && pair.notifyOnError) { notify( id = pairId.toInt() + RESULT_ID_OFFSET, channelId = CHANNEL_ALERTS, @@ -146,19 +156,21 @@ class SyncWorker @AssistedInject constructor( companion object { const val KEY_PAIR_ID = "pair_id" + const val KEY_SILENT = "silent" + const val KEY_RESULT_SUMMARY = "result_summary" private const val NOTIFICATION_ID = 1001 private const val RESULT_ID_OFFSET = 2000 private const val CHANNEL_PROGRESS = "sync_progress" private const val CHANNEL_COMPLETE = "sync_complete" private const val CHANNEL_ALERTS = "sync_alerts" - fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest { + fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean, silent: Boolean = false): OneTimeWorkRequest { val constraints = Constraints.Builder() .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiresCharging(chargingOnly) .build() return OneTimeWorkRequestBuilder() - .setInputData(workDataOf(KEY_PAIR_ID to pairId)) + .setInputData(workDataOf(KEY_PAIR_ID to pairId, KEY_SILENT to silent)) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .addTag("sync_$pairId") diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 28aa6d2..102fd6a 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -3,9 +3,8 @@ + android:centerX="0.5" + android:centerY="0.4" + android:startColor="#1B2A3B" + android:endColor="#06090E"/> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index abbdc73..3e10f6e 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -7,60 +7,44 @@ android:viewportHeight="108"> - + + android:strokeWidth="9" + android:strokeLineCap="round"> + android:startX="79.9" android:startY="56.3" + android:endX="28.1" android:endY="56.3" + android:startColor="#40C4FF" + android:endColor="#00E5FF"/> - + + - + + android:strokeWidth="9" + android:strokeLineCap="round"> + android:startX="28.1" android:startY="51.7" + android:endX="79.9" android:endY="51.7" + android:startColor="#00E5FF" + android:endColor="#40C4FF"/> - - - - - - - - - + + diff --git a/version.properties b/version.properties index 66acc41..d31376a 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.24 -VERSION_CODE=25 +VERSION_NAME=1.0.25 +VERSION_CODE=26