Initial commit — SyncFlow Android file sync app
Supports WebDAV, SFTP, SFTPGo, Nextcloud, ownCloud, Google Drive, Dropbox, and OneDrive. Credentials encrypted with Android Keystore. Biometric app-lock, conflict resolution, and auto-sync via WorkManager. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user