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:
2026-05-22 20:21:20 +00:00
commit cff4233de6
95 changed files with 5381 additions and 0 deletions
@@ -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)
}