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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.R
|
||||
import com.syncflow.domain.model.ProviderType
|
||||
|
||||
// All providers in display order
|
||||
private val ALL_PROVIDERS = listOf(
|
||||
ProviderType.NEXTCLOUD,
|
||||
ProviderType.OWNCLOUD,
|
||||
ProviderType.SFTPGO,
|
||||
ProviderType.WEBDAV,
|
||||
ProviderType.SFTP,
|
||||
ProviderType.GOOGLE_DRIVE,
|
||||
ProviderType.DROPBOX,
|
||||
ProviderType.ONEDRIVE,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSetupScreen(
|
||||
onDone: () -> Unit,
|
||||
vm: AccountSetupViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.done) { if (state.done) onDone() }
|
||||
|
||||
val googleAccountLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val name = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
|
||||
if (name != null) vm.onGoogleAccountChosen(name)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (state.providerType == null) "Choose a service" else state.providerType!!.friendlyName(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if (state.providerType != null) vm.update { copy(providerType = null, testResult = null, error = null) }
|
||||
else onDone()
|
||||
}) {
|
||||
Icon(
|
||||
if (state.providerType != null) Icons.Default.ArrowBack else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.providerType != null) {
|
||||
TextButton(
|
||||
onClick = vm::save,
|
||||
enabled = !state.isSaving && state.testResult is TestResult.Success,
|
||||
) {
|
||||
if (state.isSaving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
else Text("Save")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (state.providerType == null) {
|
||||
ProviderPickerContent(
|
||||
modifier = Modifier.padding(padding),
|
||||
onPick = { vm.update { copy(providerType = it) } },
|
||||
)
|
||||
} else {
|
||||
CredentialContent(
|
||||
state = state,
|
||||
modifier = Modifier.padding(padding),
|
||||
vm = vm,
|
||||
onDropboxConnect = {
|
||||
launchDropboxOAuth(context, vm.credentialStore, context.getString(R.string.dropbox_app_key))
|
||||
},
|
||||
onGoogleSignIn = {
|
||||
val intent = AccountManager.newChooseAccountIntent(
|
||||
null, null, arrayOf("com.google"), null, null, null, null,
|
||||
)
|
||||
googleAccountLauncher.launch(intent)
|
||||
},
|
||||
onOneDriveSignIn = {
|
||||
launchOneDriveOAuth(context, vm.credentialStore, context.getString(R.string.onedrive_client_id))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 1: Provider picker grid ────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ProviderPickerContent(modifier: Modifier = Modifier, onPick: (ProviderType) -> Unit) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"Select the service you want to sync with",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(ALL_PROVIDERS) { provider ->
|
||||
ProviderCard(provider = provider, onClick = { onPick(provider) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderCard(provider: ProviderType, onClick: () -> Unit) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.4f)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 3.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(provider.iconRes()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
provider.friendlyName(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
provider.subtitle()?.let { sub ->
|
||||
Text(
|
||||
sub,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 2: Credential form ──────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CredentialContent(
|
||||
state: AccountSetupState,
|
||||
modifier: Modifier,
|
||||
vm: AccountSetupViewModel,
|
||||
onDropboxConnect: () -> Unit,
|
||||
onGoogleSignIn: () -> Unit,
|
||||
onOneDriveSignIn: () -> Unit,
|
||||
) {
|
||||
val provider = state.providerType ?: return
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Account display name
|
||||
OutlinedTextField(
|
||||
value = state.displayName,
|
||||
onValueChange = { vm.update { copy(displayName = it) } },
|
||||
label = { Text("Account name (optional)") },
|
||||
placeholder = { Text(provider.friendlyName()) },
|
||||
leadingIcon = { Icon(Icons.Default.Badge, null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Provider-specific fields
|
||||
when {
|
||||
provider == ProviderType.GOOGLE_DRIVE -> OAuthSection(
|
||||
providerName = "Google Drive",
|
||||
email = state.oauthEmail,
|
||||
isConnected = state.oauthToken.isNotBlank(),
|
||||
onConnect = onGoogleSignIn,
|
||||
connectLabel = "Choose Google Account",
|
||||
description = "Picks any Google account already added to this device.",
|
||||
)
|
||||
provider == ProviderType.DROPBOX -> OAuthSection(
|
||||
providerName = "Dropbox",
|
||||
email = state.oauthEmail,
|
||||
isConnected = state.oauthToken.isNotBlank(),
|
||||
onConnect = onDropboxConnect,
|
||||
connectLabel = "Authorize with Dropbox",
|
||||
description = "Opens Dropbox in your browser. Works with any Dropbox account.",
|
||||
)
|
||||
provider == ProviderType.ONEDRIVE -> OAuthSection(
|
||||
providerName = "OneDrive",
|
||||
email = state.oauthEmail,
|
||||
isConnected = state.oauthToken.isNotBlank(),
|
||||
onConnect = onOneDriveSignIn,
|
||||
connectLabel = "Sign in with Microsoft",
|
||||
description = "Personal, work, or school Microsoft account.",
|
||||
)
|
||||
provider == ProviderType.SFTP -> SftpFields(state, vm)
|
||||
else -> ServerFields(provider, state, vm)
|
||||
}
|
||||
|
||||
// Test Connection (required before Save)
|
||||
HorizontalDivider()
|
||||
Button(
|
||||
onClick = vm::testConnection,
|
||||
enabled = !state.isTestingConnection && credentialsFilled(state),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = when (state.testResult) {
|
||||
is TestResult.Success -> MaterialTheme.colorScheme.primary
|
||||
is TestResult.Failure -> MaterialTheme.colorScheme.error
|
||||
null -> MaterialTheme.colorScheme.secondary
|
||||
},
|
||||
),
|
||||
) {
|
||||
when {
|
||||
state.isTestingConnection -> {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Testing…")
|
||||
}
|
||||
state.testResult is TestResult.Success -> {
|
||||
Icon(Icons.Default.CheckCircle, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Connected — tap Save ↑")
|
||||
}
|
||||
state.testResult is TestResult.Failure -> {
|
||||
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Retry Test Connection")
|
||||
}
|
||||
else -> {
|
||||
Icon(Icons.Default.NetworkCheck, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Test Connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val r = state.testResult) {
|
||||
is TestResult.Failure -> Text(r.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
is TestResult.Success -> Text("Connection successful!", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
|
||||
null -> if (credentialsFilled(state)) Text("Test required before saving.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Credential field composables ────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun OAuthSection(
|
||||
providerName: String,
|
||||
email: String,
|
||||
isConnected: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
connectLabel: String,
|
||||
description: String,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (!isConnected) {
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Column {
|
||||
Text("Authorized", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
if (email.isNotBlank()) Text(email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onConnect, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(if (isConnected) Icons.Default.SwitchAccount else Icons.Default.Login, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isConnected) "Switch account" else connectLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerFields(provider: ProviderType, state: AccountSetupState, vm: AccountSetupViewModel) {
|
||||
val urlHint = when (provider) {
|
||||
ProviderType.NEXTCLOUD -> "https://cloud.example.com"
|
||||
ProviderType.OWNCLOUD -> "https://owncloud.example.com"
|
||||
ProviderType.SFTPGO -> "https://sftpgo.example.com"
|
||||
else -> "https://dav.example.com"
|
||||
}
|
||||
val urlNote = when (provider) {
|
||||
ProviderType.NEXTCLOUD -> "Just the base URL — WebDAV path is added automatically."
|
||||
ProviderType.OWNCLOUD -> "Just the base URL — WebDAV path is added automatically."
|
||||
ProviderType.SFTPGO -> "Base URL only — SFTPGo WebDAV path added automatically."
|
||||
else -> null
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = { vm.update { copy(serverUrl = it) } },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text(urlHint) },
|
||||
leadingIcon = { Icon(Icons.Default.Language, null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
urlNote?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
if (state.httpWarning) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"HTTP sends credentials in plaintext. Use HTTPS for security.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = { vm.update { copy(username = it) } },
|
||||
label = { Text("Username") },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SftpFields(state: AccountSetupState, vm: AccountSetupViewModel) {
|
||||
var useKey by remember { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = { vm.update { copy(serverUrl = it) } },
|
||||
label = { Text("Hostname or IP") },
|
||||
placeholder = { Text("sftp.example.com") },
|
||||
leadingIcon = { Icon(Icons.Default.Dns, null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.port,
|
||||
onValueChange = { vm.update { copy(port = it) } },
|
||||
label = { Text("Port") },
|
||||
placeholder = { Text("22") },
|
||||
leadingIcon = { Icon(Icons.Default.SettingsEthernet, null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = { vm.update { copy(username = it) } },
|
||||
label = { Text("Username") },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(checked = useKey, onCheckedChange = { useKey = it })
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Use SSH private key", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (useKey) {
|
||||
OutlinedTextField(
|
||||
value = state.privateKey,
|
||||
onValueChange = { vm.update { copy(privateKey = it) } },
|
||||
label = { Text("Private key (PEM)") },
|
||||
placeholder = { Text("-----BEGIN RSA PRIVATE KEY-----\n…") },
|
||||
modifier = Modifier.fillMaxWidth().height(120.dp),
|
||||
)
|
||||
} else {
|
||||
PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(value: String, onValueChange: (String) -> Unit, label: String) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { visible = !visible }) {
|
||||
Icon(if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun credentialsFilled(state: AccountSetupState): Boolean {
|
||||
val p = state.providerType ?: return false
|
||||
return when {
|
||||
p.isOAuth() -> state.oauthToken.isNotBlank()
|
||||
p == ProviderType.SFTP -> state.serverUrl.isNotBlank() && state.username.isNotBlank()
|
||||
else -> state.serverUrl.isNotBlank() && state.username.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProviderType.iconRes() = when (this) {
|
||||
ProviderType.NEXTCLOUD -> R.drawable.ic_provider_nextcloud
|
||||
ProviderType.OWNCLOUD -> R.drawable.ic_provider_owncloud
|
||||
ProviderType.SFTPGO -> R.drawable.ic_provider_sftpgo
|
||||
ProviderType.WEBDAV -> R.drawable.ic_provider_webdav
|
||||
ProviderType.SFTP -> R.drawable.ic_provider_sftp
|
||||
ProviderType.GOOGLE_DRIVE -> R.drawable.ic_provider_googledrive
|
||||
ProviderType.DROPBOX -> R.drawable.ic_provider_dropbox
|
||||
ProviderType.ONEDRIVE -> R.drawable.ic_provider_onedrive
|
||||
}
|
||||
|
||||
private fun ProviderType.subtitle() = when (this) {
|
||||
ProviderType.NEXTCLOUD -> "Self-hosted"
|
||||
ProviderType.OWNCLOUD -> "Self-hosted"
|
||||
ProviderType.SFTPGO -> "Self-hosted"
|
||||
ProviderType.WEBDAV -> "Any WebDAV server"
|
||||
ProviderType.SFTP -> "SSH file transfer"
|
||||
ProviderType.GOOGLE_DRIVE -> "Google account"
|
||||
ProviderType.DROPBOX -> "Dropbox account"
|
||||
ProviderType.ONEDRIVE -> "Microsoft account"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import com.syncflow.data.providers.ProviderFactory
|
||||
import com.syncflow.data.repository.AccountRepository
|
||||
import com.syncflow.domain.model.ProviderType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.*
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AccountSetupState(
|
||||
val providerType: ProviderType? = null,
|
||||
val displayName: String = "",
|
||||
val serverUrl: String = "",
|
||||
val port: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val privateKey: String = "",
|
||||
val oauthToken: String = "",
|
||||
val oauthEmail: String = "",
|
||||
val httpWarning: Boolean = false,
|
||||
val isTestingConnection: Boolean = false,
|
||||
val testResult: TestResult? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val done: Boolean = false,
|
||||
)
|
||||
|
||||
sealed interface TestResult {
|
||||
object Success : TestResult
|
||||
data class Failure(val message: String) : TestResult
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AccountSetupViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val providerFactory: ProviderFactory,
|
||||
val credentialStore: com.syncflow.data.security.CredentialStore,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(AccountSetupState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val oauthReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val token = intent.getStringExtra(OAUTH_EXTRA_TOKEN) ?: return
|
||||
val email = intent.getStringExtra(OAUTH_EXTRA_EMAIL) ?: ""
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
oauthToken = token,
|
||||
oauthEmail = email,
|
||||
displayName = s.displayName.ifBlank { email.ifBlank { s.providerType?.friendlyName() ?: "" } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
LocalBroadcastManager.getInstance(context)
|
||||
.registerReceiver(oauthReceiver, IntentFilter(OAUTH_REDIRECT_ACTION))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalBroadcastManager.getInstance(context).unregisterReceiver(oauthReceiver)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun update(transform: AccountSetupState.() -> AccountSetupState) {
|
||||
_state.update { s ->
|
||||
val next = transform(s).copy(testResult = null)
|
||||
next.copy(httpWarning = next.serverUrl.startsWith("http://", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
||||
fun onGoogleAccountChosen(accountName: String) {
|
||||
_state.update { it.copy(oauthEmail = accountName, oauthToken = "google_account:$accountName", displayName = it.displayName.ifBlank { accountName }) }
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
val entity = buildEntity() ?: run {
|
||||
_state.update { it.copy(error = "Fill in all required fields first") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isTestingConnection = true, testResult = null, error = null) }
|
||||
val provider = runCatching { providerFactory.create(entity.toDomain()) }.getOrElse { e ->
|
||||
_state.update { it.copy(isTestingConnection = false, testResult = TestResult.Failure(e.message ?: "Provider error")) }
|
||||
return@launch
|
||||
}
|
||||
val result = provider.testConnection()
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
isTestingConnection = false,
|
||||
testResult = if (result.isSuccess) TestResult.Success else TestResult.Failure(result.exceptionOrNull()?.message ?: "Connection failed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val built = buildEntityWithCreds() ?: run {
|
||||
_state.update { it.copy(error = "Fill in all required fields") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSaving = true, error = null) }
|
||||
runCatching { accountRepository.insert(built.first, built.second) }
|
||||
.onSuccess { _state.update { it.copy(done = true) } }
|
||||
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns Pair(entity, credentialJson) or null if validation fails. */
|
||||
private fun buildEntityWithCreds(): Pair<CloudAccountEntity, String>? {
|
||||
val s = _state.value
|
||||
val provider = s.providerType ?: return null
|
||||
val credJson: String
|
||||
val serverUrl: String?
|
||||
val port: Int?
|
||||
val email: String?
|
||||
|
||||
when {
|
||||
provider.isOAuth() -> {
|
||||
if (s.oauthToken.isBlank()) return null
|
||||
credJson = buildJsonObject { put("access_token", s.oauthToken) }.toString()
|
||||
serverUrl = null
|
||||
port = null
|
||||
email = s.oauthEmail.ifBlank { null }
|
||||
}
|
||||
provider == ProviderType.SFTP -> {
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank()) return null
|
||||
credJson = buildJsonObject {
|
||||
put("username", s.username)
|
||||
if (s.privateKey.isNotBlank()) put("private_key", s.privateKey)
|
||||
else put("password", s.password)
|
||||
}.toString()
|
||||
serverUrl = s.serverUrl
|
||||
port = s.port.toIntOrNull() ?: 22
|
||||
email = s.username
|
||||
}
|
||||
else -> {
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank()) return null
|
||||
credJson = buildJsonObject {
|
||||
put("username", s.username)
|
||||
put("password", s.password)
|
||||
}.toString()
|
||||
serverUrl = s.serverUrl.trimEnd('/')
|
||||
port = s.port.toIntOrNull()
|
||||
email = s.username
|
||||
}
|
||||
}
|
||||
|
||||
val entity = CloudAccountEntity(
|
||||
displayName = s.displayName.ifBlank { provider.friendlyName() },
|
||||
email = email,
|
||||
providerType = provider,
|
||||
credentialJson = "", // never persisted to plaintext DB
|
||||
serverUrl = serverUrl,
|
||||
port = port,
|
||||
)
|
||||
return Pair(entity, credJson)
|
||||
}
|
||||
|
||||
// testConnection() still uses in-memory credentials — build a temporary CloudAccount
|
||||
private fun buildEntity(): CloudAccountEntity? = buildEntityWithCreds()?.let { (entity, cred) ->
|
||||
entity.copy(credentialJson = cred)
|
||||
}
|
||||
}
|
||||
|
||||
fun ProviderType.isOAuth() = this in setOf(ProviderType.GOOGLE_DRIVE, ProviderType.DROPBOX, ProviderType.ONEDRIVE)
|
||||
|
||||
fun ProviderType.friendlyName() = when (this) {
|
||||
ProviderType.GOOGLE_DRIVE -> "Google Drive"
|
||||
ProviderType.DROPBOX -> "Dropbox"
|
||||
ProviderType.ONEDRIVE -> "OneDrive"
|
||||
ProviderType.WEBDAV -> "WebDAV"
|
||||
ProviderType.SFTP -> "SFTP"
|
||||
ProviderType.NEXTCLOUD -> "Nextcloud"
|
||||
ProviderType.OWNCLOUD -> "ownCloud"
|
||||
ProviderType.SFTPGO -> "SFTPGo"
|
||||
}
|
||||
|
||||
private fun CloudAccountEntity.toDomain() = com.syncflow.domain.model.CloudAccount(
|
||||
id = id, displayName = displayName, email = email, providerType = providerType,
|
||||
credentialJson = credentialJson, serverUrl = serverUrl, port = port,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
// Removed — OneDrive auth is now handled via OAuthHelper (PKCE + Chrome Custom Tab)
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import com.syncflow.data.security.CredentialStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
const val OAUTH_REDIRECT_ACTION = "com.syncflow.OAUTH_RESULT"
|
||||
const val OAUTH_EXTRA_TOKEN = "token"
|
||||
const val OAUTH_EXTRA_EMAIL = "email"
|
||||
const val OAUTH_EXTRA_PROVIDER = "provider"
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val random = SecureRandom()
|
||||
|
||||
private fun generateVerifier(): String {
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun generateChallenge(verifier: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
|
||||
val verifier = generateVerifier()
|
||||
credentialStore.savePkceVerifier("dropbox", verifier)
|
||||
val challenge = generateChallenge(verifier)
|
||||
val url = "https://www.dropbox.com/oauth2/authorize" +
|
||||
"?client_id=$appKey" +
|
||||
"&response_type=code" +
|
||||
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
|
||||
"&code_challenge=$challenge" +
|
||||
"&code_challenge_method=S256" +
|
||||
"&token_access_type=offline"
|
||||
openCustomTab(context, url)
|
||||
}
|
||||
|
||||
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
|
||||
val verifier = generateVerifier()
|
||||
credentialStore.savePkceVerifier("onedrive", verifier)
|
||||
val challenge = generateChallenge(verifier)
|
||||
val scopes = "Files.ReadWrite+User.Read+offline_access"
|
||||
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
|
||||
"?client_id=$clientId" +
|
||||
"&response_type=code" +
|
||||
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
|
||||
"&scope=$scopes" +
|
||||
"&code_challenge=$challenge" +
|
||||
"&code_challenge_method=S256"
|
||||
openCustomTab(context, url)
|
||||
}
|
||||
|
||||
private fun openCustomTab(context: Context, url: String) {
|
||||
try {
|
||||
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
|
||||
} catch (_: Exception) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exchangeDropboxCode(
|
||||
credentialStore: CredentialStore,
|
||||
code: String,
|
||||
appKey: String,
|
||||
): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||
val verifier = credentialStore.getPkceVerifier("dropbox") ?: return@withContext null
|
||||
credentialStore.removePkceVerifier("dropbox")
|
||||
val body = FormBody.Builder()
|
||||
.add("code", code)
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", appKey)
|
||||
.add("redirect_uri", "syncflow://oauth/dropbox")
|
||||
.add("code_verifier", verifier)
|
||||
.build()
|
||||
val req = Request.Builder().url("https://api.dropboxapi.com/oauth2/token").post(body).build()
|
||||
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
|
||||
val text = resp.body?.string() ?: return@withContext null
|
||||
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
|
||||
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
|
||||
val email = json["account_id"]?.jsonPrimitive?.content ?: ""
|
||||
Pair(token, email)
|
||||
}
|
||||
|
||||
suspend fun exchangeOneDriveCode(
|
||||
credentialStore: CredentialStore,
|
||||
code: String,
|
||||
clientId: String,
|
||||
): Pair<String, String>? = withContext(Dispatchers.IO) {
|
||||
val verifier = credentialStore.getPkceVerifier("onedrive") ?: return@withContext null
|
||||
credentialStore.removePkceVerifier("onedrive")
|
||||
val body = FormBody.Builder()
|
||||
.add("code", code)
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("client_id", clientId)
|
||||
.add("redirect_uri", "syncflow://oauth/onedrive")
|
||||
.add("code_verifier", verifier)
|
||||
.add("scope", "Files.ReadWrite User.Read offline_access")
|
||||
.build()
|
||||
val req = Request.Builder().url("https://login.microsoftonline.com/common/oauth2/v2.0/token").post(body).build()
|
||||
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
|
||||
val text = resp.body?.string() ?: return@withContext null
|
||||
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
|
||||
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
|
||||
Pair(token, "")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.syncflow.ui.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.syncflow.data.security.CredentialStore
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class OAuthRedirectActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var credentialStore: CredentialStore
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val uri = intent.data ?: run { finish(); return }
|
||||
val code = uri.getQueryParameter("code") ?: run { finish(); return }
|
||||
val provider = when {
|
||||
uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox"
|
||||
uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive"
|
||||
else -> run { finish(); return }
|
||||
}
|
||||
val appKey = getString(com.syncflow.R.string.dropbox_app_key)
|
||||
val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
|
||||
lifecycleScope.launch {
|
||||
val result = when (provider) {
|
||||
"dropbox" -> exchangeDropboxCode(credentialStore, code, appKey)
|
||||
"onedrive" -> exchangeOneDriveCode(credentialStore, code, odClientId)
|
||||
else -> null
|
||||
} ?: run { finish(); return@launch }
|
||||
val broadcast = Intent(OAUTH_REDIRECT_ACTION).apply {
|
||||
putExtra(OAUTH_EXTRA_TOKEN, result.first)
|
||||
putExtra(OAUTH_EXTRA_EMAIL, result.second)
|
||||
putExtra(OAUTH_EXTRA_PROVIDER, provider)
|
||||
}
|
||||
LocalBroadcastManager.getInstance(this@OAuthRedirectActivity).sendBroadcast(broadcast)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.syncflow.ui.browser
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.domain.model.RemoteFile
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RemoteBrowserDialog(
|
||||
accountId: Long,
|
||||
initialPath: String = "/",
|
||||
onSelect: (path: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
vm: RemoteBrowserViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(accountId) { vm.init(accountId, initialPath) }
|
||||
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.85f),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column {
|
||||
// Title bar
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
state.currentPath,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
|
||||
Icon(Icons.Default.ArrowBack, null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Select current folder
|
||||
TextButton(onClick = { onSelect(state.currentPath) }) {
|
||||
Text("Select here")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") }
|
||||
}
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.entries, key = { it.path }) { entry ->
|
||||
BrowserEntry(
|
||||
file = entry,
|
||||
onClick = {
|
||||
if (entry.isDirectory) vm.navigateTo(entry.path)
|
||||
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
|
||||
},
|
||||
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowserEntry(
|
||||
file: RemoteFile,
|
||||
onClick: () -> Unit,
|
||||
onSelectFolder: (() -> Unit)?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!file.isDirectory) {
|
||||
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
if (onSelectFolder != null) {
|
||||
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.formatBytes(): String = when {
|
||||
this < 1024 -> "${this}B"
|
||||
this < 1_048_576 -> "${"%.1f".format(this / 1024.0)}KB"
|
||||
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)}MB"
|
||||
else -> "${"%.1f".format(this / 1_073_741_824.0)}GB"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.syncflow.ui.browser
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.syncflow.data.providers.ProviderFactory
|
||||
import com.syncflow.data.repository.AccountRepository
|
||||
import com.syncflow.domain.model.RemoteFile
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BrowserState(
|
||||
val accountId: Long = -1L,
|
||||
val currentPath: String = "/",
|
||||
val pathStack: List<String> = listOf("/"),
|
||||
val entries: List<RemoteFile> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RemoteBrowserViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val providerFactory: ProviderFactory,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(BrowserState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
fun init(accountId: Long, startPath: String = "/") {
|
||||
_state.update { it.copy(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath)) }
|
||||
loadPath(accountId, startPath)
|
||||
}
|
||||
|
||||
fun navigateTo(path: String) {
|
||||
val accountId = _state.value.accountId
|
||||
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path) }
|
||||
loadPath(accountId, path)
|
||||
}
|
||||
|
||||
fun navigateUp(): Boolean {
|
||||
val stack = _state.value.pathStack
|
||||
if (stack.size <= 1) return false
|
||||
val newStack = stack.dropLast(1)
|
||||
val parent = newStack.last()
|
||||
_state.update { it.copy(currentPath = parent, pathStack = newStack) }
|
||||
loadPath(_state.value.accountId, parent)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun loadPath(accountId: Long, path: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
val account = accountRepository.getAccount(accountId)
|
||||
if (account == null) {
|
||||
_state.update { it.copy(isLoading = false, error = "Account not found") }
|
||||
return@launch
|
||||
}
|
||||
val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
return@launch
|
||||
}
|
||||
provider.listFiles(path)
|
||||
.onSuccess { files ->
|
||||
_state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.syncflow.ui.conflict
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.data.db.entities.SyncConflictEntity
|
||||
import com.syncflow.domain.model.ConflictResolution
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConflictScreen(onBack: () -> Unit, vm: ConflictViewModel = hiltViewModel()) {
|
||||
val conflicts by vm.conflicts.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Conflicts (${conflicts.size})") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
|
||||
actions = {
|
||||
if (conflicts.isNotEmpty()) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { showMenu = true }) { Icon(Icons.Default.MoreVert, "Menu") }
|
||||
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Keep all local") },
|
||||
onClick = { vm.resolveAll(ConflictResolution.KEEP_LOCAL); showMenu = false },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Keep all remote") },
|
||||
onClick = { vm.resolveAll(ConflictResolution.KEEP_REMOTE); showMenu = false },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Keep both (rename)") },
|
||||
onClick = { vm.resolveAll(ConflictResolution.KEEP_BOTH); showMenu = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (conflicts.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.CheckCircle, null, Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("No conflicts!", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(conflicts, key = { it.id }) { conflict ->
|
||||
ConflictCard(conflict = conflict, onResolve = { vm.resolve(conflict, it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConflictCard(conflict: SyncConflictEntity, onResolve: (ConflictResolution) -> Unit) {
|
||||
val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||
val zone = ZoneId.systemDefault()
|
||||
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
conflict.relativePath,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
VersionBox(
|
||||
label = "Local",
|
||||
modifiedAt = fmt.format(conflict.localModifiedAt.atZone(zone)),
|
||||
size = conflict.localSizeBytes.formatBytes(),
|
||||
icon = Icons.Default.PhoneAndroid,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
)
|
||||
Icon(Icons.Default.SwapHoriz, null, modifier = Modifier.align(Alignment.CenterVertically))
|
||||
VersionBox(
|
||||
label = "Remote",
|
||||
modifiedAt = fmt.format(conflict.remoteModifiedAt.atZone(zone)),
|
||||
size = conflict.remoteSizeBytes.formatBytes(),
|
||||
icon = Icons.Default.Cloud,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { onResolve(ConflictResolution.KEEP_LOCAL) }, modifier = Modifier.weight(1f)) {
|
||||
Text("Keep Local", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
OutlinedButton(onClick = { onResolve(ConflictResolution.KEEP_REMOTE) }, modifier = Modifier.weight(1f)) {
|
||||
Text("Keep Remote", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
OutlinedButton(onClick = { onResolve(ConflictResolution.KEEP_BOTH) }, modifier = Modifier.weight(1f)) {
|
||||
Text("Keep Both", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionBox(label: String, modifiedAt: String, size: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier, color: androidx.compose.ui.graphics.Color) {
|
||||
Surface(modifier = modifier, shape = MaterialTheme.shapes.small, color = color) {
|
||||
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(icon, null, Modifier.size(20.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
|
||||
Text(modifiedAt, style = MaterialTheme.typography.labelSmall)
|
||||
Text(size, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.formatBytes(): String = when {
|
||||
this < 1024 -> "${this}B"
|
||||
this < 1024 * 1024 -> "${"%.1f".format(this / 1024.0)}KB"
|
||||
this < 1024 * 1024 * 1024 -> "${"%.1f".format(this / 1024.0 / 1024)}MB"
|
||||
else -> "${"%.1f".format(this / 1024.0 / 1024 / 1024)}GB"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.syncflow.ui.conflict
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.syncflow.data.db.SyncConflictDao
|
||||
import com.syncflow.data.db.entities.SyncConflictEntity
|
||||
import com.syncflow.domain.model.ConflictResolution
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ConflictViewModel @Inject constructor(
|
||||
private val conflictDao: SyncConflictDao,
|
||||
savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val pairId = savedState.get<Long>("pairId")!!
|
||||
|
||||
val conflicts = conflictDao.observeUnresolved(pairId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun resolve(conflict: SyncConflictEntity, resolution: ConflictResolution) {
|
||||
viewModelScope.launch { conflictDao.resolve(conflict.id, resolution) }
|
||||
}
|
||||
|
||||
fun resolveAll(resolution: ConflictResolution) {
|
||||
viewModelScope.launch {
|
||||
conflicts.value.forEach { conflictDao.resolve(it.id, resolution) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.syncflow.ui.home
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.domain.model.SyncStatus
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onAddPair: () -> Unit,
|
||||
onPairClick: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
vm: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val pairs by vm.syncPairs.collectAsState()
|
||||
|
||||
if (pairs.isEmpty()) {
|
||||
EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(pairs, key = { it.id }) { pair ->
|
||||
SyncPairCard(
|
||||
pair = pair,
|
||||
onClick = { onPairClick(pair.id) },
|
||||
onSync = { vm.triggerSync(pair) },
|
||||
onToggle = { vm.toggleEnabled(pair) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncPairCard(
|
||||
pair: SyncPairEntity,
|
||||
onClick: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(pair.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
pair.localPath.takeLast(40),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = pair.isEnabled, onCheckedChange = { onToggle() })
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusChip(pair.lastSyncResult)
|
||||
if (pair.pendingConflicts > 0) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text("${pair.pendingConflicts} conflicts") },
|
||||
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
pair.lastSyncAt?.let { at ->
|
||||
Text(
|
||||
at.formatRelative(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(status: SyncStatus) {
|
||||
val (icon, label, color) = when (status) {
|
||||
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
|
||||
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
|
||||
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
|
||||
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
|
||||
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber, "Partial", MaterialTheme.colorScheme.tertiaryContainer)
|
||||
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(label) },
|
||||
leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) },
|
||||
colors = AssistChipDefaults.assistChipColors(containerColor = color),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Outlined.CloudSync, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Tap + to create your first sync", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(onClick = onAdd) { Text("Add Sync Pair") }
|
||||
}
|
||||
}
|
||||
|
||||
private val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||
private fun Instant.formatRelative(): String = fmt.format(atZone(ZoneId.systemDefault()))
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.syncflow.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkManager
|
||||
import com.syncflow.data.db.SyncPairDao
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.domain.model.ScheduleType
|
||||
import com.syncflow.worker.SyncWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val syncPairDao: SyncPairDao,
|
||||
private val workManager: WorkManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val syncPairs = syncPairDao.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun triggerSync(pair: SyncPairEntity) {
|
||||
val req = SyncWorker.buildOneTimeRequest(pair.id, pair.wifiOnly, pair.chargingOnly)
|
||||
workManager.enqueue(req)
|
||||
}
|
||||
|
||||
fun toggleEnabled(pair: SyncPairEntity) {
|
||||
viewModelScope.launch {
|
||||
syncPairDao.update(pair.copy(isEnabled = !pair.isEnabled))
|
||||
if (!pair.isEnabled && pair.scheduleType != ScheduleType.MANUAL) {
|
||||
val req = SyncWorker.buildPeriodicRequest(
|
||||
pair.id,
|
||||
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
|
||||
pair.wifiOnly,
|
||||
pair.chargingOnly,
|
||||
)
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"periodic_${pair.id}",
|
||||
androidx.work.ExistingPeriodicWorkPolicy.UPDATE,
|
||||
req,
|
||||
)
|
||||
} else {
|
||||
workManager.cancelAllWorkByTag("sync_${pair.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.syncflow.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ManageAccounts
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.outlined.ManageAccounts
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.syncflow.ui.home.HomeScreen
|
||||
import com.syncflow.ui.settings.SettingsScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MainShell(
|
||||
onAddPair: () -> Unit,
|
||||
onPairClick: (Long) -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { 2 })
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentPage = pagerState.currentPage
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("SyncFlow", fontWeight = FontWeight.Bold) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = currentPage == 0,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
|
||||
icon = {
|
||||
Icon(
|
||||
if (currentPage == 0) Icons.Filled.Sync else Icons.Outlined.Sync,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
label = { Text("Syncs") },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = currentPage == 1,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
|
||||
icon = {
|
||||
Icon(
|
||||
if (currentPage == 1) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
label = { Text("Accounts") },
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = currentPage == 0,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text("Add Sync") },
|
||||
icon = { Icon(Icons.Default.Add, null) },
|
||||
onClick = onAddPair,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
|
||||
1 -> SettingsScreen(onAddAccount = onAddAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.syncflow.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.syncflow.ui.addpair.AddPairScreen
|
||||
import com.syncflow.ui.auth.AccountSetupScreen
|
||||
import com.syncflow.ui.conflict.ConflictScreen
|
||||
import com.syncflow.ui.main.MainShell
|
||||
import com.syncflow.ui.pairdetail.PairDetailScreen
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
object Main : Screen("main")
|
||||
object AddPair : Screen("add_pair?pairId={pairId}") {
|
||||
fun route(pairId: Long? = null) = if (pairId != null) "add_pair?pairId=$pairId" else "add_pair"
|
||||
}
|
||||
object PairDetail : Screen("pair/{pairId}") {
|
||||
fun route(pairId: Long) = "pair/$pairId"
|
||||
}
|
||||
object Conflicts : Screen("conflicts/{pairId}") {
|
||||
fun route(pairId: Long) = "conflicts/$pairId"
|
||||
}
|
||||
object AddAccount : Screen("add_account")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SyncFlowNavGraph(navController: NavHostController) {
|
||||
NavHost(navController = navController, startDestination = Screen.Main.route) {
|
||||
composable(Screen.Main.route) {
|
||||
MainShell(
|
||||
onAddPair = { navController.navigate(Screen.AddPair.route()) },
|
||||
onPairClick = { id -> navController.navigate(Screen.PairDetail.route(id)) },
|
||||
onAddAccount = { navController.navigate(Screen.AddAccount.route) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "add_pair?pairId={pairId}",
|
||||
arguments = listOf(navArgument("pairId") { type = NavType.LongType; defaultValue = -1L }),
|
||||
) {
|
||||
AddPairScreen(onDone = { navController.popBackStack() })
|
||||
}
|
||||
composable(
|
||||
route = "pair/{pairId}",
|
||||
arguments = listOf(navArgument("pairId") { type = NavType.LongType }),
|
||||
) {
|
||||
PairDetailScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onConflicts = { id -> navController.navigate(Screen.Conflicts.route(id)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "conflicts/{pairId}",
|
||||
arguments = listOf(navArgument("pairId") { type = NavType.LongType }),
|
||||
) {
|
||||
ConflictScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(Screen.AddAccount.route) {
|
||||
AccountSetupScreen(onDone = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.syncflow.ui.pairdetail
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.data.db.entities.SyncEventEntity
|
||||
import com.syncflow.domain.model.SyncEventType
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PairDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onConflicts: (Long) -> Unit,
|
||||
vm: PairDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val pair by vm.pair.collectAsState()
|
||||
val events by vm.events.collectAsState()
|
||||
val conflictCount by vm.unresolvedConflicts.collectAsState()
|
||||
var showDelete by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDelete = false },
|
||||
title = { Text("Delete sync pair?") },
|
||||
text = { Text("This removes the pair and all sync history. Files are NOT deleted.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.delete(); onBack() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showDelete = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(pair?.name ?: "…") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
|
||||
actions = {
|
||||
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
|
||||
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
pair?.let { p ->
|
||||
InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictCount > 0) {
|
||||
item {
|
||||
FilledTonalButton(
|
||||
onClick = { pair?.let { onConflicts(it.id) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("$conflictCount unresolved conflict${if (conflictCount != 1) "s" else ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text("Activity", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
if (events.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("No sync activity yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(events, key = { it.id }) { event ->
|
||||
EventRow(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath)
|
||||
InfoRow(Icons.Default.Cloud, "Remote", remotePath)
|
||||
InfoRow(Icons.Default.SwapHoriz, "Direction", direction)
|
||||
InfoRow(Icons.Default.Schedule, "Schedule", schedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("$label: ", style = MaterialTheme.typography.labelMedium)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: SyncEventEntity) {
|
||||
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
val zone = ZoneId.systemDefault()
|
||||
val (icon, tint) = eventIcon(event.eventType)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, null, Modifier.size(16.dp), tint = tint)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(event.filePath ?: event.message ?: event.eventType.name, style = MaterialTheme.typography.bodySmall)
|
||||
event.message?.takeIf { event.filePath != null }?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Text(fmt.format(event.timestamp.atZone(zone)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun eventIcon(type: SyncEventType): Pair<androidx.compose.ui.graphics.vector.ImageVector, androidx.compose.ui.graphics.Color> {
|
||||
val green = MaterialTheme.colorScheme.primary
|
||||
val red = MaterialTheme.colorScheme.error
|
||||
val orange = MaterialTheme.colorScheme.tertiary
|
||||
val grey = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
return when (type) {
|
||||
SyncEventType.SYNC_STARTED -> Pair(Icons.Default.PlayArrow, green)
|
||||
SyncEventType.SYNC_COMPLETED -> Pair(Icons.Default.CheckCircle, green)
|
||||
SyncEventType.SYNC_FAILED -> Pair(Icons.Default.Error, red)
|
||||
SyncEventType.FILE_UPLOADED -> Pair(Icons.Default.Upload, green)
|
||||
SyncEventType.FILE_DOWNLOADED -> Pair(Icons.Default.Download, green)
|
||||
SyncEventType.FILE_DELETED -> Pair(Icons.Default.Delete, orange)
|
||||
SyncEventType.FILE_SKIPPED -> Pair(Icons.Default.SkipNext, grey)
|
||||
SyncEventType.CONFLICT_DETECTED -> Pair(Icons.Default.Warning, orange)
|
||||
SyncEventType.CONFLICT_RESOLVED -> Pair(Icons.Default.CheckCircle, green)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.syncflow.ui.pairdetail
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkManager
|
||||
import com.syncflow.data.db.SyncConflictDao
|
||||
import com.syncflow.data.db.SyncEventDao
|
||||
import com.syncflow.data.db.SyncPairDao
|
||||
import com.syncflow.worker.SyncWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PairDetailViewModel @Inject constructor(
|
||||
private val syncPairDao: SyncPairDao,
|
||||
private val eventDao: SyncEventDao,
|
||||
private val conflictDao: SyncConflictDao,
|
||||
private val workManager: WorkManager,
|
||||
savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val pairId = savedState.get<Long>("pairId")!!
|
||||
|
||||
val pair = syncPairDao.observeById(pairId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
val events = eventDao.observeRecent(pairId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val unresolvedConflicts = conflictDao.observeUnresolvedCount(pairId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
fun syncNow() {
|
||||
val p = pair.value ?: return
|
||||
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, p.wifiOnly, p.chargingOnly))
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
viewModelScope.launch {
|
||||
pair.value?.let { syncPairDao.delete(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.syncflow.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import com.syncflow.domain.model.ProviderType
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onAddAccount: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
vm: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val accounts by vm.accounts.collectAsState()
|
||||
val biometricEnabled by vm.biometricEnabled.collectAsState()
|
||||
var deleteTarget by remember { mutableStateOf<CloudAccountEntity?>(null) }
|
||||
|
||||
deleteTarget?.let { acct ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
title = { Text("Remove account?") },
|
||||
text = { Text("\"${acct.displayName}\" and all associated sync pairs will be removed.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { vm.removeAccount(acct); deleteTarget = null },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) { Text("Remove") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Cloud Accounts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
FilledTonalButton(onClick = onAddAccount) {
|
||||
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Add Account")
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
if (accounts.isEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.CloudOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("No accounts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Add a cloud account to start syncing", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
OutlinedButton(onClick = onAddAccount) {
|
||||
Icon(Icons.Default.Add, null, Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Add your first account")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(accounts, key = { it.id }) { acct ->
|
||||
AccountCard(acct = acct, onDelete = { deleteTarget = acct })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Security", style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Biometric Lock", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
"Require biometrics when returning to app",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("About", style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("SyncFlow v1.0.0 — Free, no subscription.", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Open source. No ads. No tracking.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(providerIcon(acct.providerType), null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(acct.displayName, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
buildString {
|
||||
append(friendlyProviderName(acct.providerType))
|
||||
acct.email?.let { append(" · $it") }
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
acct.serverUrl?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, "Remove", tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerIcon(type: ProviderType) = when (type) {
|
||||
ProviderType.GOOGLE_DRIVE -> Icons.Default.Cloud
|
||||
ProviderType.DROPBOX -> Icons.Default.CloudQueue
|
||||
ProviderType.ONEDRIVE -> Icons.Default.CloudDone
|
||||
ProviderType.WEBDAV -> Icons.Default.Storage
|
||||
ProviderType.SFTP -> Icons.Default.Terminal
|
||||
ProviderType.NEXTCLOUD -> Icons.Default.CloudCircle
|
||||
ProviderType.OWNCLOUD -> Icons.Default.CloudCircle
|
||||
ProviderType.SFTPGO -> Icons.Default.Storage
|
||||
}
|
||||
|
||||
private fun friendlyProviderName(type: ProviderType) = when (type) {
|
||||
ProviderType.GOOGLE_DRIVE -> "Google Drive"
|
||||
ProviderType.DROPBOX -> "Dropbox"
|
||||
ProviderType.ONEDRIVE -> "OneDrive"
|
||||
ProviderType.WEBDAV -> "WebDAV"
|
||||
ProviderType.SFTP -> "SFTP"
|
||||
ProviderType.NEXTCLOUD -> "Nextcloud"
|
||||
ProviderType.OWNCLOUD -> "ownCloud"
|
||||
ProviderType.SFTPGO -> "SFTPGo"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.syncflow.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import com.syncflow.data.preferences.AppPreferences
|
||||
import com.syncflow.data.repository.AccountRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val appPreferences: AppPreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
val accounts = accountRepository.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val biometricEnabled = appPreferences.biometricLockEnabled
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
fun setBiometricLock(enabled: Boolean) {
|
||||
viewModelScope.launch { appPreferences.setBiometricLock(enabled) }
|
||||
}
|
||||
|
||||
fun removeAccount(account: CloudAccountEntity) {
|
||||
viewModelScope.launch { accountRepository.delete(account) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.syncflow.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val SyncBlue = Color(0xFF2196F3)
|
||||
val SyncGreen = Color(0xFF4CAF50)
|
||||
val SyncOrange = Color(0xFFFF9800)
|
||||
val SyncRed = Color(0xFFF44336)
|
||||
val SyncPurple = Color(0xFF9C27B0)
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.syncflow.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = SyncBlue,
|
||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||
secondary = SyncGreen,
|
||||
tertiary = SyncPurple,
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = SyncBlue,
|
||||
secondary = SyncGreen,
|
||||
tertiary = SyncPurple,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SyncFlowTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val ctx = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
||||
}
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
MaterialTheme(colorScheme = colorScheme, typography = Typography(), content = content)
|
||||
}
|
||||
Reference in New Issue
Block a user