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
@@ -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))
}
}