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,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)
}
}