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 { 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)) Spacer(Modifier.height(4.dp))
// Local folder — tap opens Android system picker (SAF), same as Autosync // Local folder
Column { Box(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth()) { OutlinedTextField(
OutlinedTextField( value = uriToDisplay(s.localPath), onValueChange = {},
value = uriToDisplay(s.localPath), onValueChange = {}, label = { Text("Local folder") },
label = { Text("Local folder") }, leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
trailingIcon = { Icon(Icons.Default.FolderOpen, null) }, readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Tap to choose folder…") },
placeholder = { Text("Tap to choose folder…") }, )
) Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
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)
}
} }
// Remote folder // 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() val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val accounts = accountRepository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null) private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list -> 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 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 ────────────────────────────────────────────── // ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) { private fun downloadAndOpen(file: SyncFileStateEntity) {
@@ -53,6 +53,7 @@ fun HomeScreen(
onClick = { onPairClick(pair.id) }, onClick = { onPairClick(pair.id) },
onSync = { vm.triggerSync(pair) }, onSync = { vm.triggerSync(pair) },
onToggle = { vm.toggleEnabled(pair) }, onToggle = { vm.toggleEnabled(pair) },
onPause = { vm.pauseSync(pair) },
) )
} }
item { Spacer(Modifier.height(80.dp)) } item { Spacer(Modifier.height(80.dp)) }
@@ -66,6 +67,7 @@ private fun SyncPairCard(
onClick: () -> Unit, onClick: () -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
onToggle: () -> Unit, onToggle: () -> Unit,
onPause: () -> Unit = {},
) { ) {
val accentColor = pair.lastSyncResult.accentColor val accentColor = pair.lastSyncResult.accentColor
@@ -170,13 +172,16 @@ private fun SyncPairCard(
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)), animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "cardRotation", label = "cardRotation",
) )
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { when (pair.lastSyncResult) {
Icon( SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) {
Icons.Default.Sync, "Sync now", Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp))
modifier = Modifier.size(18.dp).graphicsLayer { }
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation 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) { val (icon, label) = when (status) {
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced") SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…") SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
SyncStatus.PAUSED -> Pair(Icons.Default.Pause, "Paused")
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed") SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict") SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial") SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
@@ -247,11 +253,12 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
private val SyncStatus.accentColor: Color private val SyncStatus.accentColor: Color
@Composable get() = when (this) { @Composable get() = when (this) {
SyncStatus.SUCCESS -> Color(0xFF2E7D32) // green — done, healthy SyncStatus.SUCCESS -> Color(0xFF2E7D32)
SyncStatus.SYNCING -> Color(0xFF1565C0) // blue — in progress SyncStatus.SYNCING -> Color(0xFF1565C0)
SyncStatus.FAILED -> Color(0xFFC62828) // red — error SyncStatus.PAUSED -> Color(0xFF6A1B9A)
SyncStatus.PARTIAL -> Color(0xFFE65100) // orange — some files failed SyncStatus.FAILED -> Color(0xFFC62828)
SyncStatus.CONFLICT -> Color(0xFFF9A825) // amber — needs resolution SyncStatus.PARTIAL -> Color(0xFFE65100)
SyncStatus.CONFLICT -> Color(0xFFF9A825)
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
} }
@@ -8,6 +8,7 @@ import androidx.work.WorkManager
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.model.SyncStatus
import com.syncflow.worker.FileWatchService import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -32,6 +33,11 @@ class HomeViewModel @Inject constructor(
workManager.enqueue(req) workManager.enqueue(req)
} }
fun pauseSync(pair: SyncPairEntity) {
workManager.cancelAllWorkByTag("sync_${pair.id}")
viewModelScope.launch { syncPairDao.updateStatus(pair.id, SyncStatus.PAUSED) }
}
fun toggleEnabled(pair: SyncPairEntity) { fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch { viewModelScope.launch {
val nowEnabled = !pair.isEnabled val nowEnabled = !pair.isEnabled
@@ -66,7 +66,17 @@ fun PairDetailScreen(
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = { actions = {
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") } 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") } 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) { val (icon, label, containerColor) = when (pair.lastSyncResult) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer) SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer) 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.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer) SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", 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.SyncConflictDao
import com.syncflow.data.db.SyncEventDao import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.SyncStatus
import com.syncflow.worker.SyncWorker import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -39,6 +40,12 @@ class PairDetailViewModel @Inject constructor(
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false)) 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() { fun delete() {
viewModelScope.launch { viewModelScope.launch {
pair.value?.let { syncPairDao.delete(it) } pair.value?.let { syncPairDao.delete(it) }
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.56 VERSION_NAME=1.0.59
VERSION_CODE=57 VERSION_CODE=60