outbox sync

This commit is contained in:
m
2026-09-13 09:17:26 +02:00
parent cc533a3b24
commit b494db9cf0
16 changed files with 462 additions and 20 deletions
+1 -1
View File
@@ -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)
+32 -17
View File
@@ -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 {
+17 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
@@ -12,6 +13,21 @@
android:usesCleartextTraffic="true"
android:theme="@style/Theme.VaultDrop">
<!-- HiltWorkerFactory : désactive l'initialisation WorkManager par
défaut (androidx.startup) pour que Configuration.Provider
(VaultDropApplication) soit honoré — sinon le @HiltWorker ne peut
pas être instancié (NoSuchMethodException). -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PendingOperationDao {
@@ -12,6 +13,14 @@ interface PendingOperationDao {
@Query("SELECT * FROM pending_operations WHERE status = 'pending' ORDER BY id ASC LIMIT :limit")
suspend fun selectPending(limit: Int): List<PendingOperationEntity>
/** 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<List<PendingOperationEntity>>
/** Nombre d'ops encore en attente de push (indicateur du header). */
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
fun observePendingCount(): Flow<Int>
@Insert
suspend fun insert(op: PendingOperationEntity): Long
@@ -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<PendingOperationEntity> = emptyList(),
)
private val _importState = MutableStateFlow(ImportState())
val importState: StateFlow<ImportState> = _importState.asStateFlow()
private val _walkInProgress = MutableStateFlow(false)
/** Activity de synthèse : marche SAF + drain outbox + file + dernière ops. */
val syncStatus: StateFlow<SyncStatus> = 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
}
}
@@ -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
}
@@ -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 },
)
}
}
@@ -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,
@@ -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,
@@ -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<String>) -> 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,
@@ -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,
@@ -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(
@@ -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,
@@ -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<String>) -> 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,
@@ -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,
@@ -129,6 +129,26 @@
<string name="status_local">Mode local</string>
<string name="status_tap_to_recheck">Statut serveur — appuyer pour vérifier</string>
<!-- Statut de synchronisation (badge + liste outbox) -->
<string name="sync_syncing">Synchro…</string>
<string name="sync_pending_count">%1$d en attente</string>
<string name="sync_pending_count_one">1 en attente</string>
<string name="sync_idle">À jour</string>
<string name="sync_tap_to_view">Synchro — appuyer pour voir la liste des ops</string>
<string name="sync_list_title">Liste des sync</string>
<string name="sync_list_empty">Aucune opération pour le moment.</string>
<string name="sync_list_close">Fermer</string>
<string name="sync_summary_total">%1$d opérations</string>
<string name="sync_summary_pending">%1$d en attente</string>
<string name="sync_summary_failed">%1$d échouée(s)</string>
<string name="sync_status_pending">En attente</string>
<string name="sync_status_synced">Poussée</string>
<string name="sync_status_failed">Échouée</string>
<string name="sync_op_create">Création de ressource</string>
<string name="sync_op_move">Déplacement</string>
<string name="sync_op_delete">Suppression</string>
<string name="sync_op_update">Mise à jour</string>
<!-- Sélection multi-fichiers + assemblage PDF -->
<string name="selection_count">%1$d sélectionné(s)</string>
<string name="selection_assemble">Assembler en PDF</string>