outbox sync
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
|
||||
+51
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+157
@@ -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
|
||||
}
|
||||
+143
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user