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
|
- `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)
|
- `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/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`
|
- `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`
|
- `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)
|
- `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).
|
// SyncOperation is one outbox entry (shadow of mobile PendingOperationRow).
|
||||||
type SyncOperation struct {
|
type SyncOperation struct {
|
||||||
OperationID string `json:"operation_id"`
|
OperationID string `json:"operation_id"`
|
||||||
RefType string `json:"ref_type"`
|
RefType *string `json:"ref_type"`
|
||||||
RefID int64 `json:"ref_id"`
|
RefID *int64 `json:"ref_id"`
|
||||||
ResourceID string `json:"resource_id"`
|
ResourceID *string `json:"resource_id"`
|
||||||
ResourceType string `json:"resource_type"`
|
ResourceType *string `json:"resource_type"`
|
||||||
Operation string `json:"operation"`
|
Operation string `json:"operation"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func validateSyncOp(op *SyncOperation) error {
|
|||||||
if ackOnlyOps[op.Operation] {
|
if ackOnlyOps[op.Operation] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !resourceIDPattern.MatchString(op.ResourceID) {
|
if !resourceIDPattern.MatchString(derefString(op.ResourceID)) {
|
||||||
return errors.New("resource_id must be 32 lowercase hex chars")
|
return errors.New("resource_id must be 32 lowercase hex chars")
|
||||||
}
|
}
|
||||||
switch op.Operation {
|
switch op.Operation {
|
||||||
@@ -131,16 +131,17 @@ func validateSyncOp(op *SyncOperation) error {
|
|||||||
default:
|
default:
|
||||||
return errors.New("unknown operation " + op.Operation)
|
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 errors.New("resource_type must be 'folder' or 'file'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
|
func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
|
||||||
|
resourceID := derefString(op.ResourceID)
|
||||||
switch op.Operation {
|
switch op.Operation {
|
||||||
case OpCreateResource:
|
case OpCreateResource:
|
||||||
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID)
|
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -161,14 +162,14 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if op.ResourceType == "folder" {
|
if derefString(op.ResourceType) == "folder" {
|
||||||
return s.Repo.InsertFolder(ownerID, op.ResourceID, p.Name, p.ParentResourceID)
|
return s.Repo.InsertFolder(ownerID, resourceID, p.Name, p.ParentResourceID)
|
||||||
}
|
}
|
||||||
mime := p.MimeType
|
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:
|
case OpUpdateMetadata:
|
||||||
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID)
|
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -182,10 +183,10 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
|
|||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
return errors.New("payload.name required")
|
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:
|
case OpMoveResource:
|
||||||
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID)
|
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -196,13 +197,13 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
|
|||||||
if err := json.Unmarshal(op.Payload, &p); err != nil {
|
if err := json.Unmarshal(op.Payload, &p); err != nil {
|
||||||
return errors.New("invalid payload: " + err.Error())
|
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 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:
|
case OpDeleteResource:
|
||||||
return s.Repo.SyncDelete(ownerID, op.ResourceID)
|
return s.Repo.SyncDelete(ownerID, resourceID)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return errors.New("unknown operation " + op.Operation)
|
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 {
|
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 {
|
func nullableString(value string) *string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
@@ -12,6 +13,21 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.VaultDrop">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
+9
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
|
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface PendingOperationDao {
|
interface PendingOperationDao {
|
||||||
@@ -12,6 +13,14 @@ interface PendingOperationDao {
|
|||||||
@Query("SELECT * FROM pending_operations WHERE status = 'pending' ORDER BY id ASC LIMIT :limit")
|
@Query("SELECT * FROM pending_operations WHERE status = 'pending' ORDER BY id ASC LIMIT :limit")
|
||||||
suspend fun selectPending(limit: Int): List<PendingOperationEntity>
|
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
|
@Insert
|
||||||
suspend fun insert(op: PendingOperationEntity): Long
|
suspend fun insert(op: PendingOperationEntity): Long
|
||||||
|
|
||||||
|
|||||||
+51
-1
@@ -1,8 +1,13 @@
|
|||||||
package com.vaultdrop.mobile.features.sync
|
package com.vaultdrop.mobile.features.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.FolderRepository
|
||||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -11,8 +16,11 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -28,11 +36,16 @@ import javax.inject.Inject
|
|||||||
* - un échec n'interrompt pas la boucle (retry au tick suivant) ;
|
* - un échec n'interrompt pas la boucle (retry au tick suivant) ;
|
||||||
* - le scope du ViewModel arrête la boucle (cancel) à la destruction du
|
* - le scope du ViewModel arrête la boucle (cancel) à la destruction du
|
||||||
* store → le walk en cours s'arrête via `ensureActive()` du scanner.
|
* 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
|
@HiltViewModel
|
||||||
class SyncViewModel @Inject constructor(
|
class SyncViewModel @Inject constructor(
|
||||||
private val deviceSync: DeviceSync,
|
private val deviceSync: DeviceSync,
|
||||||
private val folderRepository: FolderRepository,
|
private val folderRepository: FolderRepository,
|
||||||
|
private val pendingOperationDao: PendingOperationDao,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -43,9 +56,40 @@ class SyncViewModel @Inject constructor(
|
|||||||
val error: String? = null,
|
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())
|
private val _importState = MutableStateFlow(ImportState())
|
||||||
val importState: StateFlow<ImportState> = _importState.asStateFlow()
|
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). */
|
/** Démarre la boucle une seule fois (idempotent). */
|
||||||
fun ensureStarted() {
|
fun ensureStarted() {
|
||||||
if (loopJob?.isActive == true) return
|
if (loopJob?.isActive == true) return
|
||||||
@@ -54,6 +98,7 @@ class SyncViewModel @Inject constructor(
|
|||||||
// répondre avant de lancer un walk potentiellement long.
|
// répondre avant de lancer un walk potentiellement long.
|
||||||
delay(FIRST_DELAY_MS)
|
delay(FIRST_DELAY_MS)
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
_walkInProgress.value = true
|
||||||
runCatching { deviceSync.syncAll() }
|
runCatching { deviceSync.syncAll() }
|
||||||
.onSuccess { results ->
|
.onSuccess { results ->
|
||||||
if (results.isNotEmpty()) Timber.d("syncAll: %s", results)
|
if (results.isNotEmpty()) Timber.d("syncAll: %s", results)
|
||||||
@@ -62,6 +107,7 @@ class SyncViewModel @Inject constructor(
|
|||||||
OutboxSyncWorker.enqueue(appContext)
|
OutboxSyncWorker.enqueue(appContext)
|
||||||
}
|
}
|
||||||
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
|
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
|
||||||
|
_walkInProgress.value = false
|
||||||
delay(INTERVAL_MS)
|
delay(INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +127,7 @@ class SyncViewModel @Inject constructor(
|
|||||||
if (_importState.value.isImporting) return
|
if (_importState.value.isImporting) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_importState.value = ImportState(isImporting = true)
|
_importState.value = ImportState(isImporting = true)
|
||||||
|
_walkInProgress.value = true
|
||||||
try {
|
try {
|
||||||
val saved = folderRepository.saveFolder(
|
val saved = folderRepository.saveFolder(
|
||||||
SaveFolderInput(uri = uri, name = name, exists = true),
|
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")
|
_importState.value = ImportState(error = e.message ?: "Erreur lors de l'ajout du dossier")
|
||||||
} finally {
|
} finally {
|
||||||
_importState.value = _importState.value.copy(isImporting = false)
|
_importState.value = _importState.value.copy(isImporting = false)
|
||||||
|
_walkInProgress.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,5 +155,7 @@ class SyncViewModel @Inject constructor(
|
|||||||
const val INTERVAL_MS = 30_000L
|
const val INTERVAL_MS = 30_000L
|
||||||
/** Délai avant le premier cycle (au démarrage de l'app). */
|
/** Délai avant le premier cycle (au démarrage de l'app). */
|
||||||
const val FIRST_DELAY_MS = 2_000L
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
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.ServerStatusBadge
|
||||||
|
import com.vaultdrop.mobile.ui.components.SyncStatusAction
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ fun DashboardScreen(
|
|||||||
selectedTab: NavTab,
|
selectedTab: NavTab,
|
||||||
onTabSelected: (NavTab) -> Unit,
|
onTabSelected: (NavTab) -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
onOpenReview: () -> Unit,
|
onOpenReview: () -> Unit,
|
||||||
viewModel: DashboardViewModel = hiltViewModel(),
|
viewModel: DashboardViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -50,6 +53,7 @@ fun DashboardScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.dashboard)) },
|
title = { Text(stringResource(R.string.dashboard)) },
|
||||||
actions = {
|
actions = {
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
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.data.local.entity.FileEntity
|
||||||
import com.vaultdrop.mobile.domain.FileCategory
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
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.ServerStatusBadge
|
||||||
|
import com.vaultdrop.mobile.ui.components.SyncStatusAction
|
||||||
import com.vaultdrop.mobile.ui.components.categoryValue
|
import com.vaultdrop.mobile.ui.components.categoryValue
|
||||||
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer
|
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer
|
||||||
import com.vaultdrop.mobile.ui.document.content.ImageViewer
|
import com.vaultdrop.mobile.ui.document.content.ImageViewer
|
||||||
@@ -90,6 +92,7 @@ fun DocumentViewerScreen(
|
|||||||
initialResourceId: String,
|
initialResourceId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: DocumentViewerViewModel = hiltViewModel(),
|
viewModel: DocumentViewerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val documents by viewModel.documents.collectAsStateWithLifecycle()
|
val documents by viewModel.documents.collectAsStateWithLifecycle()
|
||||||
@@ -171,6 +174,7 @@ fun DocumentViewerScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
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.FileEntity
|
||||||
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
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.FileCategoryIcon
|
||||||
import com.vaultdrop.mobile.ui.components.FolderNameDialog
|
import com.vaultdrop.mobile.ui.components.FolderNameDialog
|
||||||
import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog
|
import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog
|
||||||
import com.vaultdrop.mobile.ui.components.SelectionState
|
import com.vaultdrop.mobile.ui.components.SelectionState
|
||||||
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
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.components.rememberSelectionState
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ fun FolderDetailScreen(
|
|||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
onBuildPdf: (List<String>) -> Unit,
|
onBuildPdf: (List<String>) -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: FolderDetailViewModel = hiltViewModel(),
|
viewModel: FolderDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -161,6 +164,7 @@ fun FolderDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.create_folder),
|
contentDescription = stringResource(R.string.create_folder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
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.SelectionState
|
||||||
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
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.components.rememberSelectionState
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.MoveTargetBar
|
import com.vaultdrop.mobile.ui.navigation.MoveTargetBar
|
||||||
@@ -212,6 +213,7 @@ fun FolderListScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!uiState.moveMode && !selection.active) {
|
if (!uiState.moveMode && !selection.active) {
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ fun NavGraph(
|
|||||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
@@ -115,6 +116,7 @@ fun NavGraph(
|
|||||||
onOpenWatchedFolders = onOpenWatchedFolders,
|
onOpenWatchedFolders = onOpenWatchedFolders,
|
||||||
authViewModel = authViewModel,
|
authViewModel = authViewModel,
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.DASHBOARD) {
|
composable(Routes.DASHBOARD) {
|
||||||
@@ -122,6 +124,7 @@ fun NavGraph(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
onOpenReview = { navController.navigate(Routes.REVIEW) },
|
onOpenReview = { navController.navigate(Routes.REVIEW) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,6 +132,7 @@ fun NavGraph(
|
|||||||
SwipeReviewScreen(
|
SwipeReviewScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.WATCHED_FOLDERS) {
|
composable(Routes.WATCHED_FOLDERS) {
|
||||||
@@ -153,6 +157,7 @@ fun NavGraph(
|
|||||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@@ -168,6 +173,7 @@ fun NavGraph(
|
|||||||
initialResourceId = documentId,
|
initialResourceId = documentId,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
connectionStatusViewModel = connectionStatusViewModel,
|
connectionStatusViewModel = connectionStatusViewModel,
|
||||||
|
syncViewModel = syncViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
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.ServerStatusBadge
|
||||||
|
import com.vaultdrop.mobile.ui.components.SyncStatusAction
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -51,6 +53,7 @@ import kotlinx.coroutines.launch
|
|||||||
fun SwipeReviewScreen(
|
fun SwipeReviewScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: SwipeReviewViewModel = hiltViewModel(),
|
viewModel: SwipeReviewViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -88,6 +91,7 @@ fun SwipeReviewScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ import com.vaultdrop.mobile.R
|
|||||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
import com.vaultdrop.mobile.domain.FileCategory
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
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.FileCategoryIcon
|
||||||
import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog
|
import com.vaultdrop.mobile.ui.components.MoveFolderPickerDialog
|
||||||
import com.vaultdrop.mobile.ui.components.SelectionState
|
import com.vaultdrop.mobile.ui.components.SelectionState
|
||||||
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
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.color
|
||||||
import com.vaultdrop.mobile.ui.components.icon
|
import com.vaultdrop.mobile.ui.components.icon
|
||||||
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
||||||
@@ -77,6 +79,7 @@ fun SearchScreen(
|
|||||||
onOpenDocument: (String) -> Unit,
|
onOpenDocument: (String) -> Unit,
|
||||||
onBuildPdf: (List<String>) -> Unit,
|
onBuildPdf: (List<String>) -> Unit,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: SearchViewModel = hiltViewModel(),
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -123,6 +126,7 @@ fun SearchScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!selection.active) {
|
if (!selection.active) {
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
|
|||||||
@@ -49,10 +49,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.domain.ThemePreference
|
import com.vaultdrop.mobile.domain.ThemePreference
|
||||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
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.AuthState
|
||||||
import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
||||||
import com.vaultdrop.mobile.ui.auth.authErrorResFor
|
import com.vaultdrop.mobile.ui.auth.authErrorResFor
|
||||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
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.components.WatchedFoldersBadge
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
@@ -65,6 +67,7 @@ fun SettingsScreen(
|
|||||||
onOpenWatchedFolders: () -> Unit,
|
onOpenWatchedFolders: () -> Unit,
|
||||||
authViewModel: AuthViewModel,
|
authViewModel: AuthViewModel,
|
||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by viewModel.theme.collectAsStateWithLifecycle()
|
val theme by viewModel.theme.collectAsStateWithLifecycle()
|
||||||
@@ -88,6 +91,7 @@ fun SettingsScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.settings)) },
|
title = { Text(stringResource(R.string.settings)) },
|
||||||
actions = {
|
actions = {
|
||||||
|
SyncStatusAction(syncViewModel = syncViewModel)
|
||||||
ServerStatusBadge(
|
ServerStatusBadge(
|
||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
|
|||||||
@@ -129,6 +129,26 @@
|
|||||||
<string name="status_local">Mode local</string>
|
<string name="status_local">Mode local</string>
|
||||||
<string name="status_tap_to_recheck">Statut serveur — appuyer pour vérifier</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 -->
|
<!-- Sélection multi-fichiers + assemblage PDF -->
|
||||||
<string name="selection_count">%1$d sélectionné(s)</string>
|
<string name="selection_count">%1$d sélectionné(s)</string>
|
||||||
<string name="selection_assemble">Assembler en PDF</string>
|
<string name="selection_assemble">Assembler en PDF</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user