scan document + creation de note

This commit is contained in:
m
2026-09-15 09:09:49 +02:00
parent 809cf63994
commit 194fd26693
10 changed files with 255 additions and 3 deletions
@@ -18,14 +18,15 @@ 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 ; v6: processed sur files (mode review) ;
* v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner).
* v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner) ;
* v9: content sur files (corps des notes créées dans l'app).
*/
@Database(
entities = [
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
],
version = 8,
version = 9,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -51,6 +51,8 @@ data class FileEntity(
val category: String? = null,
@ColumnInfo(name = "sync_status")
val syncStatus: String = "local",
@ColumnInfo(name = "content")
val content: String? = null,
@ColumnInfo(name = "processed")
val processed: Boolean = false,
@ColumnInfo(name = "added_at")
@@ -22,6 +22,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* docs/api-v1.md §6.1).
* v8 : tables `scan_sessions` + `scan_pages` (scanner appareil photo) — session
* multi-pages persistée pour survivre au process death avant export SAF.
* v9 : ajout colonne `content` sur `files` — corps d'une note créée dans
* l'app (fichier cloud-only, uri = NULL). Null pour les fichiers importés.
*/
object Migrations {
@@ -158,7 +160,13 @@ object Migrations {
}
}
private val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `files` ADD COLUMN `content` TEXT")
}
}
val ALL: Array<Migration> = arrayOf(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8,
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
)
}
@@ -117,6 +117,56 @@ class FileRepository @Inject constructor(
suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId)
/**
* Crée une note fichier directement dans l'app : ligne `files` cloud-only
* (uri NULL — aucun copie physique, le corps est stocké dans `content`)
* avec `processed = 1` (jamais dans la file de review). Le `create_resource`
* est poussé dans la même transaction (pattern transactional outbox).
*/
suspend fun createNote(
title: String,
body: String,
folderResourceId: String,
ownerId: String? = null,
): FileEntity {
val now = System.currentTimeMillis()
val trimmedTitle = title.trim()
val noteName = if (trimmedTitle.endsWith(".txt", ignoreCase = true)) {
trimmedTitle
} else {
"$trimmedTitle.txt"
}
val entity = FileEntity(
resourceId = generateId.newResourceId(),
uri = null,
name = noteName,
folderResourceId = folderResourceId,
extension = "txt",
size = body.length.toLong(),
mimeType = "text/plain",
exists = 1,
ownerId = ownerId,
category = computeCategory("text/plain", "txt").dbValue,
syncStatus = FileStatus.CLOUD,
content = body,
processed = true,
addedAt = now,
updatedAt = now,
)
appDatabase.withTransaction {
fileDao.upsert(entity)
outboxRepository.enqueueCreateResource(
resourceId = entity.resourceId,
resourceType = "file",
name = entity.name,
parentResourceId = entity.folderResourceId,
mimeType = entity.mimeType,
extension = entity.extension,
)
}
return entity
}
suspend fun getAll(): List<FileEntity> = fileDao.getAll()
/**
@@ -1,9 +1,18 @@
package com.vaultdrop.mobile.ui.document.content
import android.content.ContentResolver
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.ui.components.categoryValue
@@ -11,6 +20,8 @@ import com.vaultdrop.mobile.ui.components.categoryValue
/**
* Lecteur central d'un document : dispatch par état puis par catégorie.
*
* - `content` non null → note créée dans l'app (fichier cloud-only, corps
* stocké localement) → lecture texte directe
* - `uri == null` → fichier cloud-only, aucun contenu local
* - PDF → rendu `PdfRenderer` intégré
* - IMAGE → rendu Coil intégré
@@ -25,6 +36,11 @@ fun DocumentContentViewer(
) {
val contentResolver: ContentResolver = LocalContext.current.contentResolver
file.content?.let { note ->
NoteDocumentViewer(text = note, modifier = modifier)
return
}
if (file.uri == null) {
CloudOnlyPlaceholder(file, modifier)
return
@@ -55,3 +71,25 @@ fun DocumentContentViewer(
)
}
}
/** Lecture d'une note créée dans l'app — corps stocké en base, pas de flux SAF. */
@Composable
private fun NoteDocumentViewer(
text: String,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxSize()) {
SelectionContainer(Modifier.weight(1f)) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
item {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@@ -30,10 +30,13 @@ import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.DocumentScanner
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -45,11 +48,14 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
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
@@ -118,6 +124,7 @@ fun FolderListScreen(
onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit,
onOpenScan: () -> Unit,
syncViewModel: SyncViewModel,
connectionStatusViewModel: ConnectionStatusViewModel,
viewModel: FolderListViewModel = hiltViewModel(),
@@ -140,6 +147,8 @@ fun FolderListScreen(
var shareTarget by remember { mutableStateOf<ShareTarget?>(null) }
var showShareMenu by remember { mutableStateOf(false) }
var shareEnabled by remember { mutableStateOf(false) }
var showCreateMenu by remember { mutableStateOf(false) }
var showNoteDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
// Le partage multi-sélection ne vaut que pour un dossier unique sélectionné,
@@ -396,6 +405,43 @@ fun FolderListScreen(
else -> FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
}
},
floatingActionButton = {
if (!selection.active && !uiState.moveMode) {
Box {
SmallFloatingActionButton(
onClick = { showCreateMenu = true },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(R.string.create_option_title),
)
}
DropdownMenu(
expanded = showCreateMenu,
onDismissRequest = { showCreateMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.create_option_scan)) },
leadingIcon = { Icon(Icons.Filled.DocumentScanner, contentDescription = null) },
onClick = {
showCreateMenu = false
onOpenScan()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.create_option_note)) },
leadingIcon = { Icon(Icons.Filled.EditNote, contentDescription = null) },
onClick = {
showCreateMenu = false
showNoteDialog = true
},
)
}
}
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding ->
Column(
@@ -440,6 +486,16 @@ fun FolderListScreen(
)
}
if (showNoteDialog) {
NoteDialog(
onDismiss = { showNoteDialog = false },
onConfirm = { title, body ->
showNoteDialog = false
viewModel.createNote(title, body)
},
)
}
val deleteReview = pendingDeleteReview
if (showDeleteWarning && deleteReview != null) {
DeleteWarningDialog(
@@ -1109,3 +1165,50 @@ private fun formatSize(bytes: Long): String {
val mb = bytes / (1_024f * 1_024f)
return String.format(Locale.getDefault(), "%.1f %s", mb, stringResource(R.string.unit_megabytes))
}
/** Création d'une note : titre requis, corps de texte libre. */
@Composable
private fun NoteDialog(
onDismiss: () -> Unit,
onConfirm: (title: String, body: String) -> Unit,
) {
var title by remember { mutableStateOf("") }
var body by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.note_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.note_title_label)) },
placeholder = { Text(stringResource(R.string.note_title_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = body,
onValueChange = { body = it },
label = { Text(stringResource(R.string.note_body_label)) },
minLines = 4,
maxLines = 8,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(title, body) },
enabled = title.isNotBlank(),
) {
Text(stringResource(R.string.note_create))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.note_cancel))
}
},
)
}
@@ -252,6 +252,35 @@ class FolderListViewModel @Inject constructor(
}
}
/**
* Crée une note fichier cloud-only dans le dossier courant de l'explorateur
* (racine par défaut si on est en haut). Le contenu reste local en V1.
*/
fun createNote(title: String, body: String) {
val trimmed = title.trim()
if (trimmed.isBlank()) {
_uiState.update { it.copy(createError = context.getString(R.string.note_empty_title)) }
return
}
viewModelScope.launch {
val targetId = _browseFolderId.value ?: _defaultRootId.value
if (targetId == null) {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) }
return@launch
}
runCatching {
fileRepository.createNote(
title = trimmed,
body = body,
folderResourceId = targetId,
ownerId = deviceIdentity.getOrCreate(),
)
}.onFailure {
_uiState.update { it.copy(createError = context.getString(R.string.new_folder_error)) }
}
}
}
/** Consomme une erreur transitoire de création (Snackbar). */
fun clearCreateError() {
_uiState.update { it.copy(createError = null) }
@@ -97,6 +97,7 @@ fun NavGraph(
onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
onOpenScan = { navController.navigate(Routes.SCAN) },
syncViewModel = syncViewModel,
connectionStatusViewModel = connectionStatusViewModel,
)
@@ -21,6 +21,16 @@
<string name="new_folder_name_required">The folder name cannot be empty.</string>
<string name="new_folder_cloud_only">This folder is cloud-only — import a physical folder first to create subfolders inside it.</string>
<string name="new_folder_error">Could not create the folder.</string>
<string name="create_option_scan">Scan a document</string>
<string name="create_option_note">Add a note</string>
<string name="create_option_title">Add</string>
<string name="note_dialog_title">New note</string>
<string name="note_title_label">Title</string>
<string name="note_title_hint">e.g. shopping list</string>
<string name="note_body_label">Note text</string>
<string name="note_cancel">Cancel</string>
<string name="note_create">Create</string>
<string name="note_empty_title">The note title cannot be empty.</string>
<string name="move_files">Move</string>
<string name="move_files_title">Move to folder</string>
<string name="move_files_count">%1$d file(s)</string>
@@ -39,6 +39,16 @@
<string name="new_folder_name_required">Le nom du dossier ne peut pas être vide.</string>
<string name="new_folder_cloud_only">Ce dossier est disponible uniquement sur le cloud — importe d\'abord un dossier physique pour y créer des sous-dossiers.</string>
<string name="new_folder_error">Impossible de créer le dossier.</string>
<string name="create_option_scan">Scanner un document</string>
<string name="create_option_note">Ajouter une note</string>
<string name="create_option_title">Ajouter</string>
<string name="note_dialog_title">Nouvelle note</string>
<string name="note_title_label">Titre</string>
<string name="note_title_hint">ex. liste de courses</string>
<string name="note_body_label">Texte de la note</string>
<string name="note_cancel">Annuler</string>
<string name="note_create">Créer</string>
<string name="note_empty_title">Le titre de la note ne peut pas être vide.</string>
<string name="move_files">Déplacer</string>
<string name="move_files_title">Déplacer vers un dossier</string>
<string name="move_files_count">%1$d fichier(s)</string>