From ec99d1ac1805c0a82ee91288b39bd4db90163fcf Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 20:01:14 +0200 Subject: [PATCH] swipe documents --- AGENTS.md | 2 +- .../mobile/data/local/AppDatabase.kt | 4 +- .../mobile/data/local/dao/FileDao.kt | 31 ++ .../mobile/data/local/entity/FileEntity.kt | 2 + .../mobile/data/local/migration/Migrations.kt | 14 +- .../mobile/data/repository/FileRepository.kt | 18 ++ .../mobile/features/saf/SafFileDeleter.kt | 52 +++ .../mobile/ui/dashboard/DashboardScreen.kt | 89 +++++- .../mobile/ui/dashboard/DashboardViewModel.kt | 20 ++ .../mobile/ui/navigation/NavGraph.kt | 9 + .../mobile/ui/review/SwipeCardDeck.kt | 299 ++++++++++++++++++ .../mobile/ui/review/SwipeReviewScreen.kt | 271 ++++++++++++++++ .../mobile/ui/review/SwipeReviewViewModel.kt | 109 +++++++ .../app/src/main/res/values/strings.xml | 16 + 14 files changed, 923 insertions(+), 13 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardViewModel.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeCardDeck.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewViewModel.kt diff --git a/AGENTS.md b/AGENTS.md index 26a3c3c..d56caca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest - Entry point: `app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt` + `MainActivity.kt` (Hilt) ; navigation Compose dans `ui/navigation/` (`NavGraph.kt`, `VaultDropApp.kt`) - Data layer — `data/`: - - `data/local/` — Room SQLite (DB `dot.db`, **version 5**, `Migrations.kt` : tables `folders`, `files`, `user_preferences`) : entités Folder/File/UserPreference, DAO, tri (`FileOrdering`) + - `data/local/` — Room SQLite (DB `dot.db`, **version 6**, `Migrations.kt` : tables `folders`, `files`, `user_preferences`) : entités Folder/File/UserPreference, DAO, tri (`FileOrdering`) ; flag `processed` sur `files` (mode review « traiter », local au device — le backlog est marqué traité à la migration v6) - `data/remote/` — Retrofit/Moshi : `ApiService.kt` + `dto/Dtos.kt` = **contrat HTTP** (`{ data, meta }`, erreurs `{ error: { code, message } }`) ; `ApiClient.kt` normalise les réponses ; interceptors OkHttp (`AuthInterceptor`, `ServerUrlInterceptor`) - `data/repository/` — `FolderRepository`, `FileRepository`, `AuthRepository` - `features/` — logique descendue côté client : diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt index 70e8a91..f57b5d2 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -12,11 +12,11 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity /* * DB SQLite locale, `dot.db` (même nom que la version Expo). * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; - * v5: created_in_app sur folders. + * v5: created_in_app sur folders ; v6: processed sur files (mode review). */ @Database( entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class], - version = 5, + version = 6, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt index d91a538..e4b1cda 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -56,4 +56,35 @@ interface FileDao { @Query("DELETE FROM files WHERE resource_id = :resourceId") suspend fun remove(resourceId: String) + + @Query("SELECT COUNT(*) FROM files WHERE \"exists\" = 1 AND processed = 0 AND uri IS NOT NULL") + fun observeUnprocessedCount(): Flow + + @Query(""" + SELECT * FROM files + WHERE "exists" = 1 + AND processed = 0 + AND uri IS NOT NULL + ORDER BY COALESCE(last_modified, added_at) DESC, name ASC + """) + fun observeUnprocessed(): Flow> + + @Query(""" + SELECT * FROM files + WHERE "exists" = 1 + AND processed = 0 + AND uri IS NOT NULL + ORDER BY COALESCE(last_modified, added_at) DESC, name ASC + """) + suspend fun getUnprocessed(): List + + @Query("UPDATE files SET processed = 1, updated_at = :updatedAt WHERE resource_id = :resourceId") + suspend fun markProcessed(resourceId: String, updatedAt: Long) + + @Query(""" + UPDATE files + SET processed = 1, updated_at = :updatedAt + WHERE "exists" = 1 AND processed = 0 AND uri IS NOT NULL + """) + suspend fun markAllProcessed(updatedAt: Long) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt index 4547afc..1c345b6 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt @@ -51,6 +51,8 @@ data class FileEntity( val category: String? = null, @ColumnInfo(name = "sync_status") val syncStatus: String = "local", + @ColumnInfo(name = "processed") + val processed: Boolean = false, @ColumnInfo(name = "added_at") val addedAt: Long, @ColumnInfo(name = "updated_at") diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt index 2860ee7..7a5844e 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -14,6 +14,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase * v5 : ajout colonne `created_in_app` sur `folders` (marqueur « créé dans * l'app » — 0 par défaut pour les dossiers importés/Parcourus SAF, 1 pour la * feature « Créer un dossier ». Utilisé pour filtrer le picker de déplacement). + * v6 : ajout colonne `processed` sur `files` (mode review « traiter »). Le + * backlog existant est marqué traité à la migration : seuls les fichiers + * découverts après la mise à jour entrent dans la file de review. */ object Migrations { @@ -75,5 +78,14 @@ object Migrations { } } - val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `files` ADD COLUMN `processed` INTEGER NOT NULL DEFAULT 0") + // Backlog = déjà traité : la file de review ne contient que les + // fichiers découverts après l'activation de la fonctionnalité. + db.execSQL("UPDATE `files` SET `processed` = 1") + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index 45fb5ff..df3fdc8 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -43,6 +43,22 @@ class FileRepository @Inject constructor( fun recentFiles(category: String?, limit: Int = RECENT_LIMIT): Flow> = fileDao.recentFiles(category = category, limit = limit) + /** Nombre de fichiers locaux non encore traités (file de review). */ + fun observeUnprocessedCount(): Flow = fileDao.observeUnprocessedCount() + + /** Fichiers locaux non traités, du plus récent au plus ancien — file de review. */ + fun observeUnprocessed(): Flow> = fileDao.observeUnprocessed() + + /** Snapshot de la file de review, chargé à l'entrée dans le mode traitement. */ + suspend fun getUnprocessed(): List = fileDao.getUnprocessed() + + /** Marque un fichier comme traité (gardé) — local au device, jamais poussé. */ + suspend fun markProcessed(resourceId: String) = + fileDao.markProcessed(resourceId, System.currentTimeMillis()) + + /** Marque tous les fichiers locaux restants comme traités. */ + suspend fun markAllProcessed() = fileDao.markAllProcessed(System.currentTimeMillis()) + suspend fun getFile(resourceId: String): FileEntity? = fileDao.getByResourceId(resourceId) @@ -77,6 +93,7 @@ class FileRepository @Inject constructor( lastModified = input.lastModified, ownerId = ownerId ?: existing?.ownerId, syncStatus = existing?.syncStatus ?: input.syncStatus ?: FileStatus.LOCAL, + processed = existing?.processed ?: false, addedAt = existing?.addedAt ?: now, updatedAt = now, ) @@ -126,6 +143,7 @@ class FileRepository @Inject constructor( lastModified = existing?.lastModified, ownerId = existing?.ownerId, syncStatus = existing?.syncStatus ?: FileStatus.CLOUD, + processed = existing?.processed ?: false, addedAt = existing?.addedAt ?: now, updatedAt = now, ) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt new file mode 100644 index 0000000..1600279 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafFileDeleter.kt @@ -0,0 +1,52 @@ +package com.vaultdrop.mobile.features.saf + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.repository.FileRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Suppression physique d'un fichier sur le device (mode review « supprimer »). + * + * Appelle `DocumentsContract.deleteDocument` sur l'uri SAF — le fichier + * disparaît aussi de l'arborescence (et donc du prochain sync). En cas de + * succès, la ligne Room est masquée (`exists = 0`) : cohérent avec la + * réconciliation (jamais de DELETE SQL), idempotent face à la marche 30s. + * + * Retourne `false` si le provider refuse la suppression (permission ou pas de + * geste delete) : dans ce cas le fichier reste dans la file de review. + */ +@Singleton +class SafFileDeleter @Inject constructor( + @ApplicationContext private val context: Context, + private val fileRepository: FileRepository, +) { + + private val resolver: ContentResolver get() = context.contentResolver + + suspend fun delete(file: FileEntity): Boolean { + val uri = file.uri ?: return false + return withContext(Dispatchers.IO) { + runCatching { + DocumentsContract.deleteDocument(resolver, Uri.parse(uri)) + }.onSuccess { deleted -> + if (deleted) { + Timber.d("deleted %s", uri) + fileRepository.markMissing(file.resourceId, System.currentTimeMillis()) + } else { + Timber.w("deleteDocument returned false for %s", uri) + } + }.onFailure { e -> + Timber.w(e, "deleteDocument failed for %s", uri) + }.getOrDefault(false) + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt index e4018c5..36e67ef 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardScreen.kt @@ -1,9 +1,19 @@ package com.vaultdrop.mobile.ui.dashboard +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -15,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vaultdrop.mobile.R import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel @@ -28,8 +39,11 @@ fun DashboardScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, connectionStatusViewModel: ConnectionStatusViewModel, + onOpenReview: () -> Unit, + viewModel: DashboardViewModel = hiltViewModel(), ) { val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() + val unprocessed by viewModel.unprocessedCount.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -47,18 +61,75 @@ fun DashboardScreen( FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) }, ) { padding -> - Box( + Column( modifier = Modifier .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center, + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - text = stringResource(R.string.dashboard_coming_soon), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 32.dp), + if (unprocessed > 0) { + ReviewCard( + count = unprocessed, + onClick = onOpenReview, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(R.string.dashboard_all_processed), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + ) + } + } + } + } + } +} + +@Composable +private fun ReviewCard( + count: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card(onClick = onClick, modifier = modifier) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.dashboard_review_title, count), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.dashboard_review_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, ) } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..89050dd --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/dashboard/DashboardViewModel.kt @@ -0,0 +1,20 @@ +package com.vaultdrop.mobile.ui.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.data.repository.FileRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class DashboardViewModel @Inject constructor( + fileRepository: FileRepository, +) : ViewModel() { + + /** Nombre de documents locaux restant à traiter (mode review). */ + val unprocessedCount: StateFlow = fileRepository.observeUnprocessedCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue = 0) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index b91d5d9..4514e54 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -24,6 +24,7 @@ import com.vaultdrop.mobile.ui.document.DocumentViewerScreen import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen import com.vaultdrop.mobile.ui.folderlist.FolderListScreen import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen +import com.vaultdrop.mobile.ui.review.SwipeReviewScreen import com.vaultdrop.mobile.ui.search.SearchScreen import com.vaultdrop.mobile.ui.settings.SettingsScreen @@ -32,6 +33,7 @@ object Routes { const val SEARCH = "search" const val SETTINGS = "settings" const val DASHBOARD = "dashboard" + const val REVIEW = "review" const val FOLDER_DETAIL = "folder/{folderResourceId}" const val ARG_FOLDER = "folderResourceId" const val DOCUMENT = "document/{documentResourceId}" @@ -115,6 +117,13 @@ fun NavGraph( selectedTab = selectedTab, onTabSelected = onTabSelected, connectionStatusViewModel = connectionStatusViewModel, + onOpenReview = { navController.navigate(Routes.REVIEW) }, + ) + } + composable(Routes.REVIEW) { + SwipeReviewScreen( + onBack = { navController.popBackStack() }, + connectionStatusViewModel = connectionStatusViewModel, ) } composable( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeCardDeck.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeCardDeck.kt new file mode 100644 index 0000000..cea5500 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeCardDeck.kt @@ -0,0 +1,299 @@ +package com.vaultdrop.mobile.ui.review + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.ui.components.FileCategoryIcon +import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** + * Pile de cartes « tinder » pour traiter les nouveaux documents. + * + * La carte du dessus affiche le contenu réel du document et glisse à + * gauche (supprimer) ou à droite (garder) dès qu'elle dépasse le seuil ; + * les cartes du dessous ne sont que des emplacements simplifiés (nom + + * catégorie), le contenu lourd n'étant chargé que pour la carte visible. + */ +@Composable +fun SwipeCardDeck( + cards: List, + onKeep: (FileEntity) -> Unit, + onDelete: (FileEntity) -> Unit, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier) { + val widthPx = with(LocalDensity.current) { maxWidth.toPx() } + val visible = cards.take(MAX_STACK) + + visible.asReversed().forEach { file -> + val depth = visible.indexOf(file) + key(file.resourceId) { + if (depth == 0) { + SwipeableTopCard( + file = file, + widthPx = widthPx, + onKeep = { onKeep(file) }, + onDelete = { onDelete(file) }, + onOpenExternalFailed = onOpenExternalFailed, + modifier = Modifier.fillMaxSize(), + ) + } else { + StackedCard( + file = file, + depth = depth, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} + +@Composable +private fun SwipeableTopCard( + file: FileEntity, + widthPx: Float, + onKeep: () -> Unit, + onDelete: () -> Unit, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + val offsetX = remember(file.resourceId) { Animatable(0f) } + val scope = rememberCoroutineScope() + val threshold = widthPx * SWIPE_THRESHOLD + + Box( + modifier = modifier + .graphicsLayer { + translationX = offsetX.value + rotationZ = (offsetX.value / widthPx) * MAX_ROTATION_DEG + } + .clip(CardShape) + .background(MaterialTheme.colorScheme.surface) + .pointerInput(file.resourceId) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + change.consume() + scope.launch { + offsetX.snapTo((offsetX.value + dragAmount).coerceIn(-widthPx, widthPx)) + } + }, + onDragEnd = { + scope.launch { + when { + offsetX.value >= threshold -> { + offsetX.animateTo(widthPx * 1.7f, tween(DRAG_OUT_MS)) + onKeep() + } + + offsetX.value <= -threshold -> { + offsetX.animateTo(-widthPx * 1.7f, tween(DRAG_OUT_MS)) + onDelete() + } + + else -> offsetX.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow)) + } + } + }, + onDragCancel = { + scope.launch { offsetX.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow)) } + }, + ) + }, + ) { + DocumentContentViewer( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + modifier = Modifier.fillMaxSize(), + ) + + ReviewCardMetaBar( + file = file, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) + + val progress = (offsetX.value / threshold).coerceIn(-1f, 1f) + ReviewSwipeLabel( + text = stringResource(R.string.review_keep), + color = KeepColor, + visible = progress > 0f, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp, top = 72.dp) + .alpha(progress), + ) + ReviewSwipeLabel( + text = stringResource(R.string.review_delete), + color = DeleteColor, + visible = progress < 0f, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp, top = 72.dp) + .alpha(-progress), + ) + } +} + +/** Emplacement de la prochaine carte : icône + nom, sans contenu lourd. */ +@Composable +private fun StackedCard( + file: FileEntity, + depth: Int, + modifier: Modifier = Modifier, +) { + val scale = 1f - depth * STACK_SHRINK + + Box( + modifier = modifier + .offset(y = (depth * STACK_OFFSET_DP).dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(CardShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FileCategoryIcon(file = file, size = 44.dp) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = file.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } + } +} + +/** Barre méta du document (nom, poids, date) en bas de la carte. */ +@Composable +private fun ReviewCardMetaBar(file: FileEntity, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FileCategoryIcon(file = file, size = 20.dp) + Text( + text = file.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "${formatSize(file.size)} • ${formatDate(file)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Badge « GARDER » / « SUPPRIMER » affiché pendant le drag. */ +@Composable +private fun ReviewSwipeLabel( + text: String, + color: Color, + visible: Boolean, + modifier: Modifier = Modifier, +) { + if (!visible) return + Surface( + modifier = modifier, + color = color, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = text, + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } +} + +private fun formatSize(bytes: Long): String { + val kb = 1024.0 + val mb = kb * 1024 + return when { + bytes >= mb -> String.format(Locale.getDefault(), "%.1f Mo", bytes / mb) + bytes >= kb -> String.format(Locale.getDefault(), "%.1f ko", bytes / kb) + else -> "$bytes o" + } +} + +private fun formatDate(file: FileEntity): String { + val millis = file.lastModified ?: file.addedAt + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()) +} + +private val CardShape = RoundedCornerShape(20.dp) +private val KeepColor = Color(0xFF2E7D32) +private val DeleteColor = Color(0xFFC62828) +private const val MAX_STACK = 3 +private const val SWIPE_THRESHOLD = 0.3f +private const val MAX_ROTATION_DEG = 12f +private const val DRAG_OUT_MS = 220 +private const val STACK_SHRINK = 0.045f +private const val STACK_OFFSET_DP = 18f \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt new file mode 100644 index 0000000..f16eef5 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewScreen.kt @@ -0,0 +1,271 @@ +package com.vaultdrop.mobile.ui.review + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +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.ui.components.ServerStatusBadge +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeReviewScreen( + onBack: () -> Unit, + connectionStatusViewModel: ConnectionStatusViewModel, + viewModel: SwipeReviewViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val openExternalErrorMsg = stringResource(R.string.document_open_error) + var confirmMarkAll by remember { androidx.compose.runtime.mutableStateOf(false) } + + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = if (state.remaining > 0) { + stringResource(R.string.review_remaining, state.remaining) + } else { + stringResource(R.string.review_title) + }, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + ServerStatusBadge( + status = connectionStatus, + onClick = connectionStatusViewModel::checkNow, + ) + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + when { + state.isLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + state.isFinished -> { + ReviewFinished( + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SwipeCardDeck( + cards = state.cards, + onKeep = viewModel::keep, + onDelete = viewModel::delete, + onOpenExternalFailed = { + scope.launch { snackbarHostState.showSnackbar(openExternalErrorMsg) } + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + ReviewActions( + onKeep = { state.cards.firstOrNull()?.let(viewModel::keep) }, + onDelete = { state.cards.firstOrNull()?.let(viewModel::delete) }, + modifier = Modifier.padding(top = 16.dp), + ) + + TextButton(onClick = { confirmMarkAll = true }) { + Text(stringResource(R.string.review_mark_all)) + } + } + } + } + } + + if (confirmMarkAll) { + Dialog(onDismissRequest = { confirmMarkAll = false }) { + androidx.compose.material3.Surface( + shape = MaterialTheme.shapes.medium, + tonalElevation = 3.dp, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.review_mark_all_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.review_mark_all_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { confirmMarkAll = false }) { + Text(stringResource(R.string.pdf_builder_cancel)) + } + TextButton( + onClick = { + confirmMarkAll = false + viewModel.markAllProcessed() + }, + ) { + Text(stringResource(R.string.review_mark_all)) + } + } + } + } + } + } +} + +@Composable +private fun ReviewActions( + onKeep: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(48.dp), + verticalAlignment = Alignment.Top, + ) { + ReviewAction( + icon = { tint, size -> Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.review_delete), tint = tint, modifier = Modifier.size(size)) }, + label = stringResource(R.string.review_delete), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + onClick = onDelete, + ) + ReviewAction( + icon = { tint, size -> Icon(imageVector = Icons.Filled.Check, contentDescription = stringResource(R.string.review_keep), tint = tint, modifier = Modifier.size(size)) }, + label = stringResource(R.string.review_keep), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onKeep, + ) + } +} + +@Composable +private fun ReviewAction( + icon: @Composable (tint: Color, size: Dp) -> Unit, + label: String, + containerColor: Color, + contentColor: Color, + onClick: () -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FloatingActionButton( + onClick = onClick, + shape = CircleShape, + containerColor = containerColor, + contentColor = contentColor, + modifier = Modifier.size(64.dp), + ) { + icon(contentColor, 28.dp) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } +} + +@Composable +private fun ReviewFinished( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding(horizontal = 32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.review_done), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + TextButton(onClick = onBack, modifier = Modifier.padding(top = 8.dp)) { + Text(stringResource(R.string.review_finish)) + } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewViewModel.kt new file mode 100644 index 0000000..3d50b2f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/review/SwipeReviewViewModel.kt @@ -0,0 +1,109 @@ +package com.vaultdrop.mobile.ui.review + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.repository.FileRepository +import com.vaultdrop.mobile.features.saf.SafFileDeleter +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * File de review « traiter » : les fichiers locaux non encore traités. + * + * Le deck est chargé à l'ouverture de l'écran. `garder` marque le fichier + * traité (flag local, jamais poussé) ; `supprimer` le supprime physiquement + * du device (SAF). Les deux retirent la carte du deck de façon optimiste ; + * en cas d'échec de suppression, le fichier est réinséré en tête. + */ +@HiltViewModel +class SwipeReviewViewModel @Inject constructor( + private val fileRepository: FileRepository, + private val safFileDeleter: SafFileDeleter, + @ApplicationContext private val context: Context, +) : ViewModel() { + + data class ReviewUiState( + val isLoading: Boolean = false, + val cards: List = emptyList(), + val total: Int = 0, + val error: String? = null, + ) { + val remaining: Int get() = cards.size + val isFinished: Boolean get() = !isLoading && cards.isEmpty() + } + + private val _uiState = MutableStateFlow(ReviewUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val files = runCatching { fileRepository.getUnprocessed() } + .getOrElse { e -> + Timber.w(e, "review: cannot load unprocessed files") + _uiState.update { + it.copy(error = context.getString(R.string.review_load_error)) + } + emptyList() + } + _uiState.update { it.copy(isLoading = false, cards = files, total = files.size) } + } + } + + /** Swipe droite (ou bouton ✓) : garder le document, le voilà traité. */ + fun keep(file: FileEntity) { + viewModelScope.launch { + runCatching { fileRepository.markProcessed(file.resourceId) } + .onFailure { Timber.w(it, "review: mark processed failed for %s", file.resourceId) } + removeOptimistically(file.resourceId) + } + } + + /** Swipe gauche (ou bouton ✗) : suppression physique du device. */ + fun delete(file: FileEntity) { + viewModelScope.launch { + removeOptimistically(file.resourceId) + val deleted = runCatching { safFileDeleter.delete(file) }.getOrDefault(false) + if (!deleted) { + Timber.w("review: delete failed for %s — card kept", file.resourceId) + _uiState.update { + it.copy( + error = context.getString(R.string.review_delete_error), + cards = listOf(file) + it.cards.filterNot { c -> c.resourceId == file.resourceId }, + ) + } + } + } + } + + /** Échappatoire : tout marquer traité d'un coup (premier lancement massif). */ + fun markAllProcessed() { + viewModelScope.launch { + runCatching { fileRepository.markAllProcessed() } + .onFailure { Timber.w(it, "review: mark all failed") } + _uiState.update { it.copy(cards = emptyList()) } + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + private fun removeOptimistically(resourceId: String) { + _uiState.update { it.copy(cards = it.cards.filterNot { c -> c.resourceId == resourceId }) } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 8a4d1b2..1479395 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -11,6 +11,22 @@ Fichiers Dossiers Dashboard à venir + %1$d document(s) à traiter + Révise-les en mode swipe : garder ou supprimer. + Tout est à jour — aucun document à traiter. + + + Nouveaux documents + %1$d restant(s) + GARDER + SUPPRIMER + Tous les documents ont été traités. + Terminer + Tout marquer comme traité + Tout marquer comme traité ? + Les documents restants seront considérés comme traités sans être revus. + Impossible de charger les documents à traiter. + Impossible de supprimer ce document — permission refusée. Remonter Déplacer ici (%1$d) Déplacer %1$d fichier(s)…