Compare commits

...

3 Commits

Author SHA1 Message Date
amir 21b7ffc7b3 v1.0.59: pause/resume sync
New PAUSED status. When a sync is running, the sync button becomes a
pause button (⏸). Tapping it cancels the WorkManager job and sets the
status to PAUSED (purple). The button then becomes a play button (▶) to
resume. Works in both the home screen card and the pair detail screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:45 +00:00
amir cb9fa1d3db v1.0.58: Files tab → dual-mode file explorer (Phone + Cloud)
Replace the synced-files list with a proper file explorer:
- Phone tab: browse all of internal storage with quick-access shortcuts
  (Camera, Downloads, Documents, Pictures, Music, Videos), breadcrumb
  navigation, search, tap folder to enter, tap file to open/share
- Cloud tab: browse connected cloud accounts, account switcher chips for
  multiple accounts, breadcrumb navigation, search, tap file to
  download+open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:46:35 +00:00
amir e59564ac07 v1.0.57: restore custom browser as primary local folder picker
Tap on local folder opens the custom browser again (not system picker).
The custom browser already shows the All files access banner if needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:24:35 +00:00
9 changed files with 632 additions and 440 deletions
@@ -70,5 +70,5 @@ enum class ScheduleType(val label: String) {
}
enum class SyncStatus {
IDLE, SYNCING, SUCCESS, PARTIAL, FAILED, CONFLICT,
IDLE, SYNCING, PAUSED, SUCCESS, PARTIAL, FAILED, CONFLICT,
}
@@ -120,26 +120,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Spacer(Modifier.height(4.dp))
// Local folder — tap opens Android system picker (SAF), same as Autosync
Column {
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
Box(modifier = Modifier.matchParentSize().clickable { safLauncher.launch(null) })
}
TextButton(
onClick = { showLocalBrowser = true },
modifier = Modifier.align(Alignment.End),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
) {
Text("Browse manually", style = MaterialTheme.typography.labelSmall)
}
// Local folder
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
}
// Remote folder
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,9 @@ class FilesViewModel @Inject constructor(
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val accounts = accountRepository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
@@ -158,6 +161,31 @@ class FilesViewModel @Inject constructor(
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
fun openCloudFile(accountId: Long, remotePath: String) {
viewModelScope.launch {
val account = accountRepository.getAccount(accountId) ?: run {
_fileAction.emit(FileAction.Error("Account not found"))
return@launch
}
val provider = providerFactory.create(account)
val fileName = remotePath.substringAfterLast('/')
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
cacheFile.parentFile?.mkdirs()
_isDownloading.value = true
try {
cacheFile.outputStream().use { out ->
provider.downloadFile(remotePath, out) { }.getOrThrow()
}
_fileAction.emit(FileAction.Open(cacheFile))
} catch (e: Exception) {
Timber.e(e, "Cloud open failed: $remotePath")
_fileAction.emit(FileAction.Error("Cannot open: ${e.message}"))
} finally {
_isDownloading.value = false
}
}
}
// ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) {
@@ -53,6 +53,7 @@ fun HomeScreen(
onClick = { onPairClick(pair.id) },
onSync = { vm.triggerSync(pair) },
onToggle = { vm.toggleEnabled(pair) },
onPause = { vm.pauseSync(pair) },
)
}
item { Spacer(Modifier.height(80.dp)) }
@@ -66,6 +67,7 @@ private fun SyncPairCard(
onClick: () -> Unit,
onSync: () -> Unit,
onToggle: () -> Unit,
onPause: () -> Unit = {},
) {
val accentColor = pair.lastSyncResult.accentColor
@@ -170,13 +172,16 @@ private fun SyncPairCard(
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "cardRotation",
)
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.Sync, "Sync now",
modifier = Modifier.size(18.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation
},
)
when (pair.lastSyncResult) {
SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp))
}
SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.PlayArrow, "Resume sync", modifier = Modifier.size(18.dp))
}
else -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp).graphicsLayer { rotationZ = syncRotation * 0f })
}
}
}
}
@@ -189,6 +194,7 @@ private fun StatusPill(status: SyncStatus) {
val (icon, label) = when (status) {
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
SyncStatus.PAUSED -> Pair(Icons.Default.Pause, "Paused")
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
@@ -247,11 +253,12 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
private val SyncStatus.accentColor: Color
@Composable get() = when (this) {
SyncStatus.SUCCESS -> Color(0xFF2E7D32) // green — done, healthy
SyncStatus.SYNCING -> Color(0xFF1565C0) // blue — in progress
SyncStatus.FAILED -> Color(0xFFC62828) // red — error
SyncStatus.PARTIAL -> Color(0xFFE65100) // orange — some files failed
SyncStatus.CONFLICT -> Color(0xFFF9A825) // amber — needs resolution
SyncStatus.SUCCESS -> Color(0xFF2E7D32)
SyncStatus.SYNCING -> Color(0xFF1565C0)
SyncStatus.PAUSED -> Color(0xFF6A1B9A)
SyncStatus.FAILED -> Color(0xFFC62828)
SyncStatus.PARTIAL -> Color(0xFFE65100)
SyncStatus.CONFLICT -> Color(0xFFF9A825)
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
}
@@ -8,6 +8,7 @@ import androidx.work.WorkManager
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.model.SyncStatus
import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -32,6 +33,11 @@ class HomeViewModel @Inject constructor(
workManager.enqueue(req)
}
fun pauseSync(pair: SyncPairEntity) {
workManager.cancelAllWorkByTag("sync_${pair.id}")
viewModelScope.launch { syncPairDao.updateStatus(pair.id, SyncStatus.PAUSED) }
}
fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch {
val nowEnabled = !pair.isEnabled
@@ -66,7 +66,17 @@ fun PairDetailScreen(
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = {
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") }
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
when (pair?.lastSyncResult) {
SyncStatus.SYNCING -> IconButton(onClick = { vm.pauseSync() }) {
Icon(Icons.Default.Pause, "Pause sync")
}
SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }) {
Icon(Icons.Default.PlayArrow, "Resume sync")
}
else -> IconButton(onClick = { vm.syncNow() }) {
Icon(Icons.Default.Sync, "Sync now")
}
}
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
},
)
@@ -142,6 +152,7 @@ private fun StatusBanner(pair: SyncPairEntity) {
val (icon, label, containerColor) = when (pair.lastSyncResult) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
SyncStatus.PAUSED -> Triple(Icons.Default.Pause, "Paused — tap ▶ to resume", MaterialTheme.colorScheme.surfaceVariant)
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
@@ -7,6 +7,7 @@ import androidx.work.WorkManager
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.SyncStatus
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
@@ -39,6 +40,12 @@ class PairDetailViewModel @Inject constructor(
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false))
}
fun pauseSync() {
val p = pair.value ?: return
workManager.cancelAllWorkByTag("sync_${p.id}")
viewModelScope.launch { syncPairDao.updateStatus(p.id, SyncStatus.PAUSED) }
}
fun delete() {
viewModelScope.launch {
pair.value?.let { syncPairDao.delete(it) }
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.56
VERSION_CODE=57
VERSION_NAME=1.0.59
VERSION_CODE=60