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 RadioGroup( label: String?, options: List, 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 } }