From b494db9cf0d111b53c442db3cf22b77ad00327eb Mon Sep 17 00:00:00 2001 From: m Date: Sun, 13 Sep 2026 09:17:26 +0200 Subject: [PATCH] outbox sync --- AGENTS.md | 2 +- backend/service/sync.go | 49 ++++-- .../app/src/main/AndroidManifest.xml | 18 +- .../data/local/dao/PendingOperationDao.kt | 9 + .../mobile/features/sync/SyncViewModel.kt | 52 +++++- .../ui/components/SyncOperationsDialog.kt | 157 ++++++++++++++++++ .../mobile/ui/components/SyncStatusBadge.kt | 143 ++++++++++++++++ .../mobile/ui/dashboard/DashboardScreen.kt | 4 + .../ui/document/DocumentViewerScreen.kt | 4 + .../ui/folderdetail/FolderDetailScreen.kt | 4 + .../mobile/ui/folderlist/FolderListScreen.kt | 2 + .../mobile/ui/navigation/NavGraph.kt | 6 + .../mobile/ui/review/SwipeReviewScreen.kt | 4 + .../mobile/ui/search/SearchScreen.kt | 4 + .../mobile/ui/settings/SettingsScreen.kt | 4 + .../app/src/main/res/values/strings.xml | 20 +++ 16 files changed, 462 insertions(+), 20 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncOperationsDialog.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncStatusBadge.kt diff --git a/AGENTS.md b/AGENTS.md index f3ea020..9494667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest - `sync/DeviceSync.kt` + `SafScanner.kt` — sync device↔SAF : **two-pass walk** (listing hors transaction puis upserts Room dans une seule transaction), réconciliation `exists = 0` (jamais de suppression), single-flight via `Mutex` ; chaque nouvelle ressource découverte est journalisée dans l'outbox - `saf/` — `SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule ; le déplacement est poussé en `move_resource`), `SafFileDeleter` (suppression SAF + `delete_resource` atomique) - `sync/OutboxSyncWorker.kt` — worker WorkManager (`enqueueUniqueWork` KEEP, single-flight, `NetworkType.CONNECTED`, backoff 30s) qui draine `pending_operations` vers `POST /sync/ops` (batch 20, dead-letter immédiat `failed` sur erreur 4xx non-idempotente — `attempts` diagnostic, purge synced > 7 jours) ; **schedulé** au login/restauration de session (`AuthViewModel`) et après chaque `syncAll()` (`SyncViewModel`) - - `sync/SyncViewModel.kt` — état du sync exposé à l'UI + - `sync/SyncViewModel.kt` — état du sync exposé à l'UI (`SyncStatus` : marche en cours, ops en attente, dernières opérations) via le badge header `ui/components/SyncStatusBadge.kt` + `SyncOperationsDialog.kt` (« liste des sync ») - `auth/` — session (login user + token paseto) : `SessionManager`, `SecureTokenStore`, `TokenProvider` - `ui/` — écrans Compose : `folderlist`, `folderdetail`, `document` (contenu PDF/image/texte + placeholder cloud-only), `search` (**recherche locale** via Room, sans endpoint serveur), `settings` (URL serveur + thème), `pdfbuilder` (multi-select → génération PDF), `auth` (login, mode local), `components`, `theme` - `domain/` — stores de préférences: `DeviceIdentity`, `ActiveUserStore`, `LocalModeStore`, `ServerConfigStore`, `ThemePreferenceStore`, `GenerateId` (identifiants 32-hex) diff --git a/backend/service/sync.go b/backend/service/sync.go index f8b84b7..9967751 100644 --- a/backend/service/sync.go +++ b/backend/service/sync.go @@ -33,10 +33,10 @@ var ackOnlyOps = map[string]bool{ // SyncOperation is one outbox entry (shadow of mobile PendingOperationRow). type SyncOperation struct { OperationID string `json:"operation_id"` - RefType string `json:"ref_type"` - RefID int64 `json:"ref_id"` - ResourceID string `json:"resource_id"` - ResourceType string `json:"resource_type"` + RefType *string `json:"ref_type"` + RefID *int64 `json:"ref_id"` + ResourceID *string `json:"resource_id"` + ResourceType *string `json:"resource_type"` Operation string `json:"operation"` Payload json.RawMessage `json:"payload"` } @@ -123,7 +123,7 @@ func validateSyncOp(op *SyncOperation) error { if ackOnlyOps[op.Operation] { return nil } - if !resourceIDPattern.MatchString(op.ResourceID) { + if !resourceIDPattern.MatchString(derefString(op.ResourceID)) { return errors.New("resource_id must be 32 lowercase hex chars") } switch op.Operation { @@ -131,16 +131,17 @@ func validateSyncOp(op *SyncOperation) error { default: return errors.New("unknown operation " + op.Operation) } - if op.ResourceType != "folder" && op.ResourceType != "file" { + if derefString(op.ResourceType) != "folder" && derefString(op.ResourceType) != "file" { return errors.New("resource_type must be 'folder' or 'file'") } return nil } func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { + resourceID := derefString(op.ResourceID) switch op.Operation { case OpCreateResource: - exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) if err != nil { return err } @@ -161,14 +162,14 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { return err } } - if op.ResourceType == "folder" { - return s.Repo.InsertFolder(ownerID, op.ResourceID, p.Name, p.ParentResourceID) + if derefString(op.ResourceType) == "folder" { + return s.Repo.InsertFolder(ownerID, resourceID, p.Name, p.ParentResourceID) } mime := p.MimeType - return s.Repo.InsertFile(ownerID, op.ResourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension)) + return s.Repo.InsertFile(ownerID, resourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension)) case OpUpdateMetadata: - exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) if err != nil { return err } @@ -182,10 +183,10 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { if p.Name == "" { return errors.New("payload.name required") } - return s.Repo.UpdateName(ownerID, op.ResourceID, p.Name) + return s.Repo.UpdateName(ownerID, resourceID, p.Name) case OpMoveResource: - exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) if err != nil { return err } @@ -196,13 +197,13 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { if err := json.Unmarshal(op.Payload, &p); err != nil { return errors.New("invalid payload: " + err.Error()) } - if p.ToFolderResourceID == op.ResourceID { + if p.ToFolderResourceID == resourceID { return errors.New("cannot move a resource into itself") } - return s.Repo.MoveResource(ownerID, op.ResourceID, p.ToFolderResourceID) + return s.Repo.MoveResource(ownerID, resourceID, p.ToFolderResourceID) case OpDeleteResource: - return s.Repo.SyncDelete(ownerID, op.ResourceID) + return s.Repo.SyncDelete(ownerID, resourceID) default: return errors.New("unknown operation " + op.Operation) @@ -210,7 +211,21 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { } func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error { - return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, op.RefType, nullableInt64(op.RefID), op.ResourceID, op.Payload) + return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, derefString(op.RefType), nullableInt64(derefInt64(op.RefID)), derefString(op.ResourceID), op.Payload) +} + +func derefString(v *string) string { + if v == nil { + return "" + } + return *v +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v } func nullableString(value string) *string { diff --git a/mobile-kotlin/app/src/main/AndroidManifest.xml b/mobile-kotlin/app/src/main/AndroidManifest.xml index bbde9ae..0a189f1 100644 --- a/mobile-kotlin/app/src/main/AndroidManifest.xml +++ b/mobile-kotlin/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -12,6 +13,21 @@ android:usesCleartextTraffic="true" android:theme="@style/Theme.VaultDrop"> + + + + + + /** Dernières opérations (tous statuts), pour l'affichage UI « liste des sync ». */ + @Query("SELECT * FROM pending_operations ORDER BY id DESC LIMIT :limit") + fun observeRecent(limit: Int): Flow> + + /** Nombre d'ops encore en attente de push (indicateur du header). */ + @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'") + fun observePendingCount(): Flow + @Insert suspend fun insert(op: PendingOperationEntity): Long diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt index 6a9798f..b7c100e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt @@ -1,8 +1,13 @@ package com.vaultdrop.mobile.features.sync +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao +import com.vaultdrop.mobile.data.local.entity.PendingOpStatus +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.SaveFolderInput import dagger.hilt.android.lifecycle.HiltViewModel @@ -11,8 +16,11 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber @@ -28,11 +36,16 @@ import javax.inject.Inject * - un échec n'interrompt pas la boucle (retry au tick suivant) ; * - le scope du ViewModel arrête la boucle (cancel) à la destruction du * store → le walk en cours s'arrête via `ensureActive()` du scanner. + * + * **Observabilité** : expose [syncStatus] pour le badge du header — marche SAF + * en cours ([SyncStatus.syncing]), nombre d'ops outbox en attente, et les + * dernières opérations ([SyncStatus.operations]) pour la « liste des sync ». */ @HiltViewModel class SyncViewModel @Inject constructor( private val deviceSync: DeviceSync, private val folderRepository: FolderRepository, + private val pendingOperationDao: PendingOperationDao, @ApplicationContext private val appContext: Context, ) : ViewModel() { @@ -43,9 +56,40 @@ class SyncViewModel @Inject constructor( val error: String? = null, ) + /** État de la synchro affiché dans le header (indicateur + liste). */ + data class SyncStatus( + /** Une marche SAF ou un drain outbox est en cours. */ + val syncing: Boolean = false, + /** Ops en attente de push (jamais poussées, outbox non vide). */ + val pending: Int = 0, + /** Ops dead-lettrées (dernières seulement, vu la fenêtre UI). */ + val failed: Int = 0, + /** Dernières opérations (tous statuts) — « liste des sync ». */ + val operations: List = emptyList(), + ) + private val _importState = MutableStateFlow(ImportState()) val importState: StateFlow = _importState.asStateFlow() + private val _walkInProgress = MutableStateFlow(false) + + /** Activity de synthèse : marche SAF + drain outbox + file + dernière ops. */ + val syncStatus: StateFlow = combine( + _walkInProgress, + WorkManager.getInstance(appContext).getWorkInfosForUniqueWorkFlow(OutboxSyncWorker.NAME), + pendingOperationDao.observePendingCount(), + pendingOperationDao.observeRecent(RECENT_LIMIT), + ) { walking, workInfos, pending, operations -> + // RUNNING = drain en cours ; ENQUEUED = planifié (attente réseau/backoff). + val draining = workInfos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } + SyncStatus( + syncing = walking || draining, + pending = pending, + failed = operations.count { it.status == PendingOpStatus.FAILED }, + operations = operations, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncStatus()) + /** Démarre la boucle une seule fois (idempotent). */ fun ensureStarted() { if (loopJob?.isActive == true) return @@ -54,6 +98,7 @@ class SyncViewModel @Inject constructor( // répondre avant de lancer un walk potentiellement long. delay(FIRST_DELAY_MS) while (isActive) { + _walkInProgress.value = true runCatching { deviceSync.syncAll() } .onSuccess { results -> if (results.isNotEmpty()) Timber.d("syncAll: %s", results) @@ -62,6 +107,7 @@ class SyncViewModel @Inject constructor( OutboxSyncWorker.enqueue(appContext) } .onFailure { e -> Timber.w(e, "syncAll failed, retrying later") } + _walkInProgress.value = false delay(INTERVAL_MS) } } @@ -81,6 +127,7 @@ class SyncViewModel @Inject constructor( if (_importState.value.isImporting) return viewModelScope.launch { _importState.value = ImportState(isImporting = true) + _walkInProgress.value = true try { val saved = folderRepository.saveFolder( SaveFolderInput(uri = uri, name = name, exists = true), @@ -94,6 +141,7 @@ class SyncViewModel @Inject constructor( _importState.value = ImportState(error = e.message ?: "Erreur lors de l'ajout du dossier") } finally { _importState.value = _importState.value.copy(isImporting = false) + _walkInProgress.value = false } } } @@ -107,5 +155,7 @@ class SyncViewModel @Inject constructor( const val INTERVAL_MS = 30_000L /** Délai avant le premier cycle (au démarrage de l'app). */ const val FIRST_DELAY_MS = 2_000L + /** Fenêtre d'affichage de la « liste des sync ». */ + const val RECENT_LIMIT = 50 } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncOperationsDialog.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncOperationsDialog.kt new file mode 100644 index 0000000..a5664b0 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncOperationsDialog.kt @@ -0,0 +1,157 @@ +package com.vaultdrop.mobile.ui.components + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.PendingOpStatus +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity +import com.vaultdrop.mobile.features.sync.SyncViewModel + +private val StatusPending = Color(0xFF1E88E5) +private val StatusSynced = Color(0xFF2E7D32) +private val StatusFailed = Color(0xFFC62828) + +/** + * Dialogue « liste des sync » : dernières opérations de l'outbox (tous statuts), + * avec un résumé (en attente / échouées) en tête de liste. + */ +@Composable +internal fun SyncOperationsDialog( + status: SyncViewModel.SyncStatus, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.sync_list_title)) }, + text = { + Column { + SyncListSummary(status) + if (status.operations.isEmpty()) { + Text( + text = stringResource(R.string.sync_list_empty), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(status.operations, key = { it.id }) { op -> + SyncOperationRow(op) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.sync_list_close)) + } + }, + ) +} + +@Composable +private fun SyncListSummary(status: SyncViewModel.SyncStatus) { + val summary = buildString { + append(stringResource(R.string.sync_summary_total, status.operations.size)) + if (status.pending > 0) { + append(" · ") + append(stringResource(R.string.sync_summary_pending, status.pending)) + } + if (status.failed > 0) { + append(" · ") + append(stringResource(R.string.sync_summary_failed, status.failed)) + } + } + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Composable +private fun SyncOperationRow(op: PendingOperationEntity) { + val (dotColor, statusLabel) = when (op.status) { + PendingOpStatus.PENDING -> StatusPending to stringResource(R.string.sync_status_pending) + PendingOpStatus.SYNCED -> StatusSynced to stringResource(R.string.sync_status_synced) + else -> StatusFailed to stringResource(R.string.sync_status_failed) + } + val opLabel = operationLabel(op.operation) + val time = DateUtils.getRelativeTimeSpanString(op.createdAt).toString() + val shortId = op.resourceId?.take(8).orEmpty() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(modifier = Modifier.size(8.dp).background(dotColor, CircleShape)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = opLabel, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = listOfNotNull( + op.resourceType?.takeIf { it.isNotBlank() }, + shortId.ifBlank { null }, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = statusLabel, + style = MaterialTheme.typography.labelSmall, + color = dotColor, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = time, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun operationLabel(operation: String): String = when (operation) { + "create_resource" -> stringResource(R.string.sync_op_create) + "move_resource" -> stringResource(R.string.sync_op_move) + "delete_resource" -> stringResource(R.string.sync_op_delete) + "update_metadata" -> stringResource(R.string.sync_op_update) + else -> operation +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncStatusBadge.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncStatusBadge.kt new file mode 100644 index 0000000..7602006 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SyncStatusBadge.kt @@ -0,0 +1,143 @@ +package com.vaultdrop.mobile.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.features.sync.SyncViewModel + +private val SyncingAccent = Color(0xFF1E88E5) +private val PendingAmber = Color(0xFFF9A825) + +/** + * Pilule de statut de synchro affichée dans le header : spinner quand une marche + * SAF ou un drain outbox est en cours, compteur d'ops en attente sinon, coche + * quand tout est poussé. Un appui ouvre la « liste des sync » (dernières ops). + */ +@Composable +fun SyncStatusBadge( + status: SyncViewModel.SyncStatus, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val (accent, label, showSpinner) = when { + status.syncing -> { + Triple(SyncingAccent, stringResource(R.string.sync_syncing), true) + } + status.pending > 0 -> { + val label = if (status.pending == 1) { + stringResource(R.string.sync_pending_count_one) + } else { + stringResource(R.string.sync_pending_count, status.pending) + } + Triple(PendingAmber, label, false) + } + else -> Triple( + MaterialTheme.colorScheme.primary, + stringResource(R.string.sync_idle), + false, + ) + } + val shape = RoundedCornerShape(percent = 50) + val tapLabel = stringResource(R.string.sync_tap_to_view) + + Row( + modifier = modifier + .clip(shape) + .clickable( + role = Role.Button, + onClickLabel = tapLabel, + onClick = onClick, + ) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (showSpinner) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + color = accent, + ) + } else if (status.pending > 0) { + Box( + modifier = Modifier + .size(14.dp) + .background(accent, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = status.pending.coerceAtMost(99).toString(), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } + } else { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = accent, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Composable prêt-à-poser dans un header : badge + dialogue « liste des sync ». + * Le dialogue est géré localement (état `showList`), aucun wiring externe. + */ +@Composable +fun SyncStatusAction( + syncViewModel: SyncViewModel, + modifier: Modifier = Modifier, +) { + val status by syncViewModel.syncStatus.collectAsStateWithLifecycle() + var showList by remember { mutableStateOf(false) } + + SyncStatusBadge( + status = status, + onClick = { showList = true }, + modifier = modifier, + ) + + if (showList) { + SyncOperationsDialog( + status = status, + onDismiss = { showList = false }, + ) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt index 36e67ef..816527e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt @@ -29,7 +29,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab @@ -39,6 +41,7 @@ fun DashboardScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, + syncViewModel: SyncViewModel, onOpenReview: () -> Unit, viewModel: DashboardViewModel = hiltViewModel(), ) { @@ -50,6 +53,7 @@ fun DashboardScreen( TopAppBar( title = { Text(stringResource(R.string.dashboard)) }, actions = { + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt index 60b548c..7376858 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -61,7 +61,9 @@ import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer import com.vaultdrop.mobile.ui.document.content.ImageViewer @@ -90,6 +92,7 @@ fun DocumentViewerScreen( initialResourceId: String, onBack: () -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, + syncViewModel: SyncViewModel, viewModel: DocumentViewerViewModel = hiltViewModel(), ) { val documents by viewModel.documents.collectAsStateWithLifecycle() @@ -171,6 +174,7 @@ fun DocumentViewerScreen( } }, actions = { + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt index a954410..b5cca61 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -50,12 +50,14 @@ import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.FolderNameDialog import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.rememberSelectionState import java.util.Locale @@ -68,6 +70,7 @@ fun FolderDetailScreen( onOpenDocument: (String) -> Unit, onBuildPdf: (List) -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, + syncViewModel: SyncViewModel, viewModel: FolderDetailViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -161,6 +164,7 @@ fun FolderDetailScreen( contentDescription = stringResource(R.string.create_folder), ) } + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index ca96bf2..ee9c942 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -77,6 +77,7 @@ import com.vaultdrop.mobile.ui.components.FolderNameDialog import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.MoveTargetBar @@ -212,6 +213,7 @@ fun FolderListScreen( }, actions = { if (!uiState.moveMode && !selection.active) { + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index 31fed6e..47bbd95 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -106,6 +106,7 @@ fun NavGraph( onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) }, connectionStatusViewModel = connectionStatusViewModel, + syncViewModel = syncViewModel, ) } composable(Routes.SETTINGS) { @@ -115,6 +116,7 @@ fun NavGraph( onOpenWatchedFolders = onOpenWatchedFolders, authViewModel = authViewModel, connectionStatusViewModel = connectionStatusViewModel, + syncViewModel = syncViewModel, ) } composable(Routes.DASHBOARD) { @@ -122,6 +124,7 @@ fun NavGraph( selectedTab = selectedTab, onTabSelected = onTabSelected, connectionStatusViewModel = connectionStatusViewModel, + syncViewModel = syncViewModel, onOpenReview = { navController.navigate(Routes.REVIEW) }, ) } @@ -129,6 +132,7 @@ fun NavGraph( SwipeReviewScreen( onBack = { navController.popBackStack() }, connectionStatusViewModel = connectionStatusViewModel, + syncViewModel = syncViewModel, ) } composable(Routes.WATCHED_FOLDERS) { @@ -153,6 +157,7 @@ fun NavGraph( onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) }, connectionStatusViewModel = connectionStatusViewModel, + syncViewModel = syncViewModel, ) } composable( @@ -168,6 +173,7 @@ fun NavGraph( initialResourceId = documentId, onBack = { navController.popBackStack() }, connectionStatusViewModel = connectionStatusViewModel, + syncViewModel = syncViewModel, ) } composable( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt index b97eba6..4fb26f3 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt @@ -43,7 +43,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -51,6 +53,7 @@ import kotlinx.coroutines.launch fun SwipeReviewScreen( onBack: () -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, + syncViewModel: SyncViewModel, viewModel: SwipeReviewViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -88,6 +91,7 @@ fun SwipeReviewScreen( } }, actions = { + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt index 3d89d92..781f3bf 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt @@ -56,11 +56,13 @@ import com.vaultdrop.mobile.R import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.components.FileCategoryIcon import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog import com.vaultdrop.mobile.ui.components.SelectionState import com.vaultdrop.mobile.ui.components.SelectionStatusIcon import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.color import com.vaultdrop.mobile.ui.components.icon import com.vaultdrop.mobile.ui.components.rememberSelectionState @@ -77,6 +79,7 @@ fun SearchScreen( onOpenDocument: (String) -> Unit, onBuildPdf: (List) -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, + syncViewModel: SyncViewModel, viewModel: SearchViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -123,6 +126,7 @@ fun SearchScreen( }, actions = { if (!selection.active) { + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt index 2974662..7f8fe6d 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt @@ -49,10 +49,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R import com.vaultdrop.mobile.domain.ThemePreference import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel +import com.vaultdrop.mobile.features.sync.SyncViewModel import com.vaultdrop.mobile.ui.auth.AuthState import com.vaultdrop.mobile.ui.auth.AuthViewModel import com.vaultdrop.mobile.ui.auth.authErrorResFor import com.vaultdrop.mobile.ui.components.ServerStatusBadge +import com.vaultdrop.mobile.ui.components.SyncStatusAction import com.vaultdrop.mobile.ui.components.WatchedFoldersBadge import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab @@ -65,6 +67,7 @@ fun SettingsScreen( onOpenWatchedFolders: () -> Unit, authViewModel: AuthViewModel, connectionStatusViewModel: ConnectionStatusViewModel, + syncViewModel: SyncViewModel, viewModel: SettingsViewModel = hiltViewModel(), ) { val theme by viewModel.theme.collectAsStateWithLifecycle() @@ -88,6 +91,7 @@ fun SettingsScreen( TopAppBar( title = { Text(stringResource(R.string.settings)) }, actions = { + SyncStatusAction(syncViewModel = syncViewModel) ServerStatusBadge( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index cffeddc..2697af4 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -129,6 +129,26 @@ Mode local Statut serveur — appuyer pour vérifier + + Synchro… + %1$d en attente + 1 en attente + À jour + Synchro — appuyer pour voir la liste des ops + Liste des sync + Aucune opération pour le moment. + Fermer + %1$d opérations + %1$d en attente + %1$d échouée(s) + En attente + Poussée + Échouée + Création de ressource + Déplacement + Suppression + Mise à jour + %1$d sélectionné(s) Assembler en PDF