Initial commit — SyncFlow Android file sync app
Supports WebDAV, SFTP, SFTPGo, Nextcloud, ownCloud, Google Drive, Dropbox, and OneDrive. Credentials encrypted with Android Keystore. Biometric app-lock, conflict resolution, and auto-sync via WorkManager. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
package com.syncflow.ui.addpair
|
||||
|
||||
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.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() }
|
||||
|
||||
var showRemoteBrowser by remember { mutableStateOf(false) }
|
||||
|
||||
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
|
||||
uri?.let { 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.syncflow.ui.addpair
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.syncflow.data.db.CloudAccountDao
|
||||
import com.syncflow.data.db.SyncPairDao
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.data.db.entities.toDomain
|
||||
import com.syncflow.domain.model.*
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AddPairUiState(
|
||||
// ── Identity ─────────────────────────────────────────────────────────────
|
||||
val name: String = "",
|
||||
// ── Folders ──────────────────────────────────────────────────────────────
|
||||
val localPath: String = "",
|
||||
val remotePath: String = "",
|
||||
val selectedAccountId: Long = -1L,
|
||||
val accounts: List<CloudAccountEntity> = emptyList(),
|
||||
// ── Sync type ────────────────────────────────────────────────────────────
|
||||
val syncDirection: SyncDirection = SyncDirection.TWO_WAY,
|
||||
val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
|
||||
val deleteBehavior: DeleteBehavior = DeleteBehavior.MIRROR,
|
||||
val recursive: Boolean = true,
|
||||
// ── Schedule ─────────────────────────────────────────────────────────────
|
||||
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
|
||||
val intervalMinutes: Int = 30,
|
||||
val dailyTime: String = "02:00",
|
||||
val weekdays: Int = 0b1111111, // all 7 days by default
|
||||
// ── Constraints ──────────────────────────────────────────────────────────
|
||||
val wifiOnly: Boolean = true,
|
||||
val wifiSsid: String = "",
|
||||
val chargingOnly: Boolean = false,
|
||||
val minBatteryPct: Int = 0,
|
||||
// ── File filters ─────────────────────────────────────────────────────────
|
||||
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db",
|
||||
val includeExtensions: String = "",
|
||||
val excludeExtensions: String = "",
|
||||
val skipHiddenFiles: Boolean = true,
|
||||
val minFileSizeKb: Long = 0L,
|
||||
val maxFileSizeKb: Long = 0L,
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
val notifyOnComplete: Boolean = false,
|
||||
val notifyOnError: Boolean = true,
|
||||
// ── Form state ───────────────────────────────────────────────────────────
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val done: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AddPairViewModel @Inject constructor(
|
||||
private val syncPairDao: SyncPairDao,
|
||||
private val accountDao: CloudAccountDao,
|
||||
savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
|
||||
|
||||
private val _state = MutableStateFlow(AddPairUiState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
accountDao.observeAll().collect { accounts ->
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
accounts = accounts,
|
||||
selectedAccountId = if (s.selectedAccountId == -1L) accounts.firstOrNull()?.id ?: -1L else s.selectedAccountId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
editPairId?.let { id ->
|
||||
viewModelScope.launch {
|
||||
syncPairDao.getById(id)?.let { pair ->
|
||||
_state.update { _ ->
|
||||
AddPairUiState(
|
||||
name = pair.name,
|
||||
localPath = pair.localPath,
|
||||
remotePath = pair.remotePath,
|
||||
selectedAccountId = pair.accountId,
|
||||
syncDirection = pair.syncDirection,
|
||||
conflictStrategy = pair.conflictStrategy,
|
||||
deleteBehavior = pair.deleteBehavior,
|
||||
recursive = pair.recursive,
|
||||
scheduleType = pair.scheduleType,
|
||||
intervalMinutes = pair.scheduleIntervalMinutes,
|
||||
dailyTime = pair.scheduleDailyTime ?: "02:00",
|
||||
weekdays = pair.scheduleWeekdays,
|
||||
wifiOnly = pair.wifiOnly,
|
||||
wifiSsid = pair.wifiSsid,
|
||||
chargingOnly = pair.chargingOnly,
|
||||
minBatteryPct = pair.minBatteryPct,
|
||||
excludePatterns = pair.excludePatterns,
|
||||
includeExtensions = pair.includeExtensions,
|
||||
excludeExtensions = pair.excludeExtensions,
|
||||
skipHiddenFiles = pair.skipHiddenFiles,
|
||||
minFileSizeKb = pair.minFileSizeKb,
|
||||
maxFileSizeKb = pair.maxFileSizeKb,
|
||||
notifyOnComplete = pair.notifyOnComplete,
|
||||
notifyOnError = pair.notifyOnError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: AddPairUiState.() -> AddPairUiState) = _state.update(transform)
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
val errors = buildList {
|
||||
if (s.name.isBlank()) add("Name is required")
|
||||
if (s.localPath.isBlank()) add("Local folder is required")
|
||||
if (s.remotePath.isBlank()) add("Remote folder is required")
|
||||
if (s.selectedAccountId == -1L) add("Select a cloud account")
|
||||
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
|
||||
}
|
||||
if (errors.isNotEmpty()) { _state.update { it.copy(error = errors.first()) }; return }
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSaving = true, error = null) }
|
||||
runCatching {
|
||||
val entity = SyncPairEntity(
|
||||
id = editPairId ?: 0L,
|
||||
name = s.name, localPath = s.localPath, remotePath = s.remotePath,
|
||||
accountId = s.selectedAccountId,
|
||||
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy,
|
||||
deleteBehavior = s.deleteBehavior, recursive = s.recursive,
|
||||
scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes,
|
||||
scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null,
|
||||
scheduleWeekdays = s.weekdays,
|
||||
wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid,
|
||||
chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct,
|
||||
excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions,
|
||||
excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles,
|
||||
minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb,
|
||||
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
|
||||
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
|
||||
)
|
||||
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity)
|
||||
}
|
||||
.onSuccess { _state.update { it.copy(done = true) } }
|
||||
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user