Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt
T
amir c8e50ac17e fix: take persistable SAF URI permission on folder selection
Without calling takePersistableUriPermission, the content:// URI
permission granted by ACTION_OPEN_DOCUMENT_TREE is revoked on
app reinstall, causing Permission Denial errors during sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:27:16 +00:00

414 lines
20 KiB
Kotlin

package com.syncflow.ui.addpair
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.*
import com.syncflow.ui.browser.RemoteBrowserDialog
import java.time.DayOfWeek
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
uri?.let {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
vm.update { copy(localPath = it.toString()) }
}
}
if (showRemoteBrowser && s.selectedAccountId != -1L) {
RemoteBrowserDialog(
accountId = s.selectedAccountId,
initialPath = s.remotePath.ifBlank { "/" },
onSelect = { path ->
vm.update { copy(remotePath = path) }
showRemoteBrowser = false
},
onDismiss = { showRemoteBrowser = false },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (s.name.isBlank()) "New Sync Pair" else s.name, fontWeight = FontWeight.SemiBold) },
navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, null) } },
actions = {
TextButton(onClick = vm::save, enabled = !s.isSaving) {
if (s.isSaving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text("Save", fontWeight = FontWeight.SemiBold)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// ── Pair name ────────────────────────────────────────────────────
Section(title = null) {
OutlinedTextField(
value = s.name, onValueChange = { vm.update { copy(name = it) } },
label = { Text("Sync pair name") },
leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
}
// ── Folders ──────────────────────────────────────────────────────
Section(title = "Folders", icon = Icons.Default.FolderOpen) {
// Account
if (s.accounts.isEmpty()) {
Text("No accounts added — go to Settings first.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
} else {
Text("Cloud account", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
s.accounts.forEachIndexed { idx, acct ->
SegmentedButton(
selected = s.selectedAccountId == acct.id,
onClick = { vm.update { copy(selectedAccountId = acct.id, remotePath = "") } },
shape = SegmentedButtonDefaults.itemShape(idx, s.accounts.size),
label = { Text(acct.displayName, maxLines = 1) },
)
}
}
}
Spacer(Modifier.height(4.dp))
// Local folder
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = {
IconButton(onClick = { dirPicker.launch(null) }) {
Icon(Icons.Default.FolderOpen, "Browse")
}
},
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
// Remote folder
OutlinedTextField(
value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } },
label = { Text("Remote folder") },
leadingIcon = { Icon(Icons.Default.Cloud, null) },
trailingIcon = {
IconButton(
onClick = { if (s.selectedAccountId != -1L) showRemoteBrowser = true },
enabled = s.selectedAccountId != -1L,
) { Icon(Icons.Default.Folder, "Browse remote") }
},
singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("/ or /Documents/Photos") },
)
// Recursive
ToggleRow(
label = "Include subfolders",
description = "Sync all nested folders recursively",
checked = s.recursive,
onToggle = { vm.update { copy(recursive = it) } },
)
}
// ── Sync type ────────────────────────────────────────────────────
Section(title = "Sync Type", icon = Icons.Default.SyncAlt) {
RadioGroup(
label = "Direction",
options = SyncDirection.entries,
selected = s.syncDirection,
onSelect = { vm.update { copy(syncDirection = it) } },
itemLabel = { "${it.label}${it.description}" },
)
Spacer(Modifier.height(8.dp))
RadioGroup(
label = "Conflict resolution",
options = ConflictStrategy.entries,
selected = s.conflictStrategy,
onSelect = { vm.update { copy(conflictStrategy = it) } },
itemLabel = { it.label },
)
Spacer(Modifier.height(8.dp))
RadioGroup(
label = "Deletion behaviour",
options = DeleteBehavior.entries,
selected = s.deleteBehavior,
onSelect = { vm.update { copy(deleteBehavior = it) } },
itemLabel = { "${it.label}${it.description}" },
)
}
// ── Schedule ─────────────────────────────────────────────────────
Section(title = "Schedule", icon = Icons.Default.Schedule) {
RadioGroup(
label = null,
options = ScheduleType.entries,
selected = s.scheduleType,
onSelect = { vm.update { copy(scheduleType = it) } },
itemLabel = { it.label },
)
AnimatedVisibility(s.scheduleType == ScheduleType.INTERVAL) {
Column(Modifier.padding(top = 8.dp)) {
OutlinedTextField(
value = s.intervalMinutes.toString(),
onValueChange = { vm.update { copy(intervalMinutes = it.toIntOrNull()?.coerceAtLeast(15) ?: 15) } },
label = { Text("Interval (minutes, min 15)") },
leadingIcon = { Icon(Icons.Default.Timer, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
}
AnimatedVisibility(s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) {
Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = s.dailyTime,
onValueChange = { vm.update { copy(dailyTime = it) } },
label = { Text("Time (HH:mm)") },
leadingIcon = { Icon(Icons.Default.AccessTime, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("02:00") },
)
if (s.scheduleType == ScheduleType.WEEKLY) {
WeekdayPicker(weekdays = s.weekdays, onChange = { vm.update { copy(weekdays = it) } })
}
}
}
}
// ── Conditions ───────────────────────────────────────────────────
Section(title = "Run Conditions", icon = Icons.Default.Tune) {
ToggleRow("Wi-Fi only", "Only sync on unmetered Wi-Fi connections", s.wifiOnly) {
vm.update { copy(wifiOnly = it, wifiSsid = if (!it) "" else wifiSsid) }
}
AnimatedVisibility(s.wifiOnly) {
OutlinedTextField(
value = s.wifiSsid,
onValueChange = { vm.update { copy(wifiSsid = it) } },
label = { Text("Specific Wi-Fi network (SSID)") },
placeholder = { Text("Leave blank for any Wi-Fi") },
leadingIcon = { Icon(Icons.Default.Wifi, null) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
ToggleRow("Charging only", "Only sync when device is charging", s.chargingOnly) {
vm.update { copy(chargingOnly = it) }
}
Spacer(Modifier.height(4.dp))
Text("Minimum battery level: ${if (s.minBatteryPct == 0) "None" else "${s.minBatteryPct}%"}",
style = MaterialTheme.typography.bodySmall)
Slider(
value = s.minBatteryPct.toFloat(),
onValueChange = { vm.update { copy(minBatteryPct = it.toInt()) } },
valueRange = 0f..90f, steps = 17,
modifier = Modifier.fillMaxWidth(),
)
}
// ── File filters ─────────────────────────────────────────────────
Section(title = "File Filters", icon = Icons.Default.FilterAlt) {
ToggleRow("Skip hidden files", "Skip files/folders starting with '.'", s.skipHiddenFiles) {
vm.update { copy(skipHiddenFiles = it) }
}
Spacer(Modifier.height(8.dp))
Text("Extensions to sync (leave blank = all files)",
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
value = s.includeExtensions,
onValueChange = { vm.update { copy(includeExtensions = it) } },
label = { Text("Include only (e.g. jpg png pdf)") },
placeholder = { Text("Space-separated, blank = all") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = s.excludeExtensions,
onValueChange = { vm.update { copy(excludeExtensions = it) } },
label = { Text("Exclude extensions (e.g. tmp bak log)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text("Exclude filename patterns",
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
value = s.excludePatterns,
onValueChange = { vm.update { copy(excludePatterns = it) } },
label = { Text("One pattern per line (supports *)") },
modifier = Modifier.fillMaxWidth().height(90.dp),
)
Spacer(Modifier.height(8.dp))
Text("File size limits (0 = no limit)",
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = if (s.minFileSizeKb == 0L) "" else s.minFileSizeKb.toString(),
onValueChange = { vm.update { copy(minFileSizeKb = it.toLongOrNull() ?: 0L) } },
label = { Text("Min size (KB)") },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = if (s.maxFileSizeKb == 0L) "" else s.maxFileSizeKb.toString(),
onValueChange = { vm.update { copy(maxFileSizeKb = it.toLongOrNull() ?: 0L) } },
label = { Text("Max size (KB)") },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
}
// ── Notifications ────────────────────────────────────────────────
Section(title = "Notifications", icon = Icons.Default.Notifications) {
ToggleRow("Notify on sync complete", "Show notification when sync finishes successfully", s.notifyOnComplete) {
vm.update { copy(notifyOnComplete = it) }
}
ToggleRow("Notify on errors", "Show notification when sync encounters errors", s.notifyOnError) {
vm.update { copy(notifyOnError = it) }
}
}
// ── Error ────────────────────────────────────────────────────────
s.error?.let { err ->
Box(Modifier.padding(horizontal = 20.dp)) {
Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
Spacer(Modifier.height(40.dp))
}
}
}
// ─── Section wrapper ──────────────────────────────────────────────────────────
@Composable
private fun Section(
title: String?,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) {
if (title != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp)) {
icon?.let {
Icon(it, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(6.dp))
}
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
}
HorizontalDivider()
Spacer(Modifier.height(8.dp))
}
content()
}
}
// ─── Reusable row components ──────────────────────────────────────────────────
@Composable
private fun ToggleRow(label: String, description: String, checked: Boolean, onToggle: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = checked, onCheckedChange = onToggle)
}
}
@Composable
private fun <T> RadioGroup(
label: String?,
options: List<T>,
selected: T,
onSelect: (T) -> Unit,
itemLabel: (T) -> String,
) {
Column {
label?.let {
Text(it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 2.dp))
}
options.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = option == selected, onClick = { onSelect(option) })
Text(itemLabel(option), style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Composable
private fun WeekdayPicker(weekdays: Int, onChange: (Int) -> Unit) {
val days = listOf("M", "T", "W", "T", "F", "S", "S")
val fullNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
Column {
Text("Repeat on", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
days.forEachIndexed { i, label ->
val bit = 1 shl i
val selected = (weekdays and bit) != 0
FilterChip(
selected = selected,
onClick = { onChange(if (selected) weekdays and bit.inv() else weekdays or bit) },
label = { Text(label) },
modifier = Modifier.weight(1f),
)
}
}
}
}
private fun uriToDisplay(uriString: String): String {
if (uriString.isBlank()) return ""
return try {
val uri = android.net.Uri.parse(uriString)
uri.lastPathSegment?.replace(":", "/") ?: uriString
} catch (e: Exception) {
uriString
}
}