c8e50ac17e
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>
414 lines
20 KiB
Kotlin
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
|
|
}
|
|
}
|