swipe documents
This commit is contained in:
@@ -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 :
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<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,
|
||||
@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")
|
||||
|
||||
+13
-1
@@ -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<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)
|
||||
}
|
||||
+18
@@ -43,6 +43,22 @@ class FileRepository @Inject constructor(
|
||||
fun recentFiles(category: String?, limit: Int = RECENT_LIMIT): Flow<List<FileEntity>> =
|
||||
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? =
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-9
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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.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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -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_folders">Dossiers</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="move_here_count">Déplacer ici (%1$d)</string>
|
||||
<string name="move_pick_title">Déplacer %1$d fichier(s)…</string>
|
||||
|
||||
Reference in New Issue
Block a user