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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 02:22:43 +00:00
parent 146b8baf9a
commit 8fdd22bc98
9 changed files with 392 additions and 215 deletions
@@ -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<File>) : FileAction()
data class Error(val message: String) : FileAction()
}
@@ -57,6 +59,12 @@ class FilesViewModel @Inject constructor(
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val selectedCount: StateFlow<Int> = _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) {