swipe documents

This commit is contained in:
m
2026-09-12 20:01:14 +02:00
parent 06361ae62f
commit ec99d1ac18
14 changed files with 923 additions and 13 deletions
+1 -1
View File
@@ -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`) - 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 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/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` - `data/repository/``FolderRepository`, `FileRepository`, `AuthRepository`
- `features/` — logique descendue côté client : - `features/` — logique descendue côté client :
@@ -12,11 +12,11 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
/* /*
* DB SQLite locale, `dot.db` (même nom que la version Expo). * DB SQLite locale, `dot.db` (même nom que la version Expo).
* v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; * 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( @Database(
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class], entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
version = 5, version = 6,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -56,4 +56,35 @@ interface FileDao {
@Query("DELETE FROM files WHERE resource_id = :resourceId") @Query("DELETE FROM files WHERE resource_id = :resourceId")
suspend fun remove(resourceId: String) suspend fun remove(resourceId: String)
@Query("SELECT COUNT(*) FROM files WHERE \"exists\" = 1 AND processed = 0 AND uri IS NOT NULL")
fun observeUnprocessedCount(): Flow<Int>
@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<List<FileEntity>>
@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<FileEntity>
@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)
} }
@@ -51,6 +51,8 @@ data class FileEntity(
val category: String? = null, val category: String? = null,
@ColumnInfo(name = "sync_status") @ColumnInfo(name = "sync_status")
val syncStatus: String = "local", val syncStatus: String = "local",
@ColumnInfo(name = "processed")
val processed: Boolean = false,
@ColumnInfo(name = "added_at") @ColumnInfo(name = "added_at")
val addedAt: Long, val addedAt: Long,
@ColumnInfo(name = "updated_at") @ColumnInfo(name = "updated_at")
@@ -14,6 +14,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* v5 : ajout colonne `created_in_app` sur `folders` (marqueur « créé dans * 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 * 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). * 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 { object Migrations {
@@ -75,5 +78,14 @@ object Migrations {
} }
} }
val ALL: Array<Migration> = 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<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
} }
@@ -43,6 +43,22 @@ class FileRepository @Inject constructor(
fun recentFiles(category: String?, limit: Int = RECENT_LIMIT): Flow<List<FileEntity>> = fun recentFiles(category: String?, limit: Int = RECENT_LIMIT): Flow<List<FileEntity>> =
fileDao.recentFiles(category = category, limit = limit) fileDao.recentFiles(category = category, limit = limit)
/** Nombre de fichiers locaux non encore traités (file de review). */
fun observeUnprocessedCount(): Flow<Int> = fileDao.observeUnprocessedCount()
/** Fichiers locaux non traités, du plus récent au plus ancien — file de review. */
fun observeUnprocessed(): Flow<List<FileEntity>> = fileDao.observeUnprocessed()
/** Snapshot de la file de review, chargé à l'entrée dans le mode traitement. */
suspend fun getUnprocessed(): List<FileEntity> = 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? = suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId) fileDao.getByResourceId(resourceId)
@@ -77,6 +93,7 @@ class FileRepository @Inject constructor(
lastModified = input.lastModified, lastModified = input.lastModified,
ownerId = ownerId ?: existing?.ownerId, ownerId = ownerId ?: existing?.ownerId,
syncStatus = existing?.syncStatus ?: input.syncStatus ?: FileStatus.LOCAL, syncStatus = existing?.syncStatus ?: input.syncStatus ?: FileStatus.LOCAL,
processed = existing?.processed ?: false,
addedAt = existing?.addedAt ?: now, addedAt = existing?.addedAt ?: now,
updatedAt = now, updatedAt = now,
) )
@@ -126,6 +143,7 @@ class FileRepository @Inject constructor(
lastModified = existing?.lastModified, lastModified = existing?.lastModified,
ownerId = existing?.ownerId, ownerId = existing?.ownerId,
syncStatus = existing?.syncStatus ?: FileStatus.CLOUD, syncStatus = existing?.syncStatus ?: FileStatus.CLOUD,
processed = existing?.processed ?: false,
addedAt = existing?.addedAt ?: now, addedAt = existing?.addedAt ?: now,
updatedAt = now, updatedAt = now,
) )
@@ -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)
}
}
}
@@ -1,9 +1,19 @@
package com.vaultdrop.mobile.ui.dashboard package com.vaultdrop.mobile.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -15,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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
@@ -28,8 +39,11 @@ fun DashboardScreen(
selectedTab: NavTab, selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
onOpenReview: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel(),
) { ) {
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val unprocessed by viewModel.unprocessedCount.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -47,18 +61,75 @@ fun DashboardScreen(
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
}, },
) { padding -> ) { padding ->
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
contentAlignment = Alignment.Center, .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text( if (unprocessed > 0) {
text = stringResource(R.string.dashboard_coming_soon), ReviewCard(
style = MaterialTheme.typography.bodyLarge, count = unprocessed,
color = MaterialTheme.colorScheme.onSurfaceVariant, onClick = onOpenReview,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.padding(horizontal = 32.dp), )
} 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,
) )
} }
} }
@@ -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<Int> = fileRepository.observeUnprocessedCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue = 0)
}
@@ -24,6 +24,7 @@ import com.vaultdrop.mobile.ui.document.DocumentViewerScreen
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen 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.search.SearchScreen
import com.vaultdrop.mobile.ui.settings.SettingsScreen import com.vaultdrop.mobile.ui.settings.SettingsScreen
@@ -32,6 +33,7 @@ object Routes {
const val SEARCH = "search" const val SEARCH = "search"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val DASHBOARD = "dashboard" const val DASHBOARD = "dashboard"
const val REVIEW = "review"
const val FOLDER_DETAIL = "folder/{folderResourceId}" const val FOLDER_DETAIL = "folder/{folderResourceId}"
const val ARG_FOLDER = "folderResourceId" const val ARG_FOLDER = "folderResourceId"
const val DOCUMENT = "document/{documentResourceId}" const val DOCUMENT = "document/{documentResourceId}"
@@ -115,6 +117,13 @@ fun NavGraph(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
onOpenReview = { navController.navigate(Routes.REVIEW) },
)
}
composable(Routes.REVIEW) {
SwipeReviewScreen(
onBack = { navController.popBackStack() },
connectionStatusViewModel = connectionStatusViewModel,
) )
} }
composable( composable(
@@ -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<FileEntity>,
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
@@ -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))
}
}
}
}
@@ -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<FileEntity> = 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<ReviewUiState> = _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 }) }
}
}
@@ -11,6 +11,22 @@
<string name="view_files">Fichiers</string> <string name="view_files">Fichiers</string>
<string name="view_folders">Dossiers</string> <string name="view_folders">Dossiers</string>
<string name="dashboard_coming_soon">Dashboard à venir</string> <string name="dashboard_coming_soon">Dashboard à venir</string>
<string name="dashboard_review_title">%1$d document(s) à traiter</string>
<string name="dashboard_review_hint">Révise-les en mode swipe : garder ou supprimer.</string>
<string name="dashboard_all_processed">Tout est à jour — aucun document à traiter.</string>
<!-- Mode review (swipe) -->
<string name="review_title">Nouveaux documents</string>
<string name="review_remaining">%1$d restant(s)</string>
<string name="review_keep">GARDER</string>
<string name="review_delete">SUPPRIMER</string>
<string name="review_done">Tous les documents ont été traités.</string>
<string name="review_finish">Terminer</string>
<string name="review_mark_all">Tout marquer comme traité</string>
<string name="review_mark_all_title">Tout marquer comme traité ?</string>
<string name="review_mark_all_message">Les documents restants seront considérés comme traités sans être revus.</string>
<string name="review_load_error">Impossible de charger les documents à traiter.</string>
<string name="review_delete_error">Impossible de supprimer ce document — permission refusée.</string>
<string name="browse_up">Remonter</string> <string name="browse_up">Remonter</string>
<string name="move_here_count">Déplacer ici (%1$d)</string> <string name="move_here_count">Déplacer ici (%1$d)</string>
<string name="move_pick_title">Déplacer %1$d fichier(s)…</string> <string name="move_pick_title">Déplacer %1$d fichier(s)…</string>