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). * 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 ; v6: processed sur files (mode review) ; * 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( @Database(
entities = [ entities = [
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class, FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class, PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
], ],
version = 8, version = 9,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -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 = "content")
val content: String? = null,
@ColumnInfo(name = "processed") @ColumnInfo(name = "processed")
val processed: Boolean = false, val processed: Boolean = false,
@ColumnInfo(name = "added_at") @ColumnInfo(name = "added_at")
@@ -22,6 +22,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* docs/api-v1.md §6.1). * docs/api-v1.md §6.1).
* v8 : tables `scan_sessions` + `scan_pages` (scanner appareil photo) — session * v8 : tables `scan_sessions` + `scan_pages` (scanner appareil photo) — session
* multi-pages persistée pour survivre au process death avant export SAF. * 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 { 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( 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? = suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId) 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() suspend fun getAll(): List<FileEntity> = fileDao.getAll()
/** /**
@@ -1,9 +1,18 @@
package com.vaultdrop.mobile.ui.document.content package com.vaultdrop.mobile.ui.document.content
import android.content.ContentResolver 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.ui.components.categoryValue 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. * 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 * - `uri == null` → fichier cloud-only, aucun contenu local
* - PDF → rendu `PdfRenderer` intégré * - PDF → rendu `PdfRenderer` intégré
* - IMAGE → rendu Coil intégré * - IMAGE → rendu Coil intégré
@@ -25,6 +36,11 @@ fun DocumentContentViewer(
) { ) {
val contentResolver: ContentResolver = LocalContext.current.contentResolver val contentResolver: ContentResolver = LocalContext.current.contentResolver
file.content?.let { note ->
NoteDocumentViewer(text = note, modifier = modifier)
return
}
if (file.uri == null) { if (file.uri == null) {
CloudOnlyPlaceholder(file, modifier) CloudOnlyPlaceholder(file, modifier)
return return
@@ -54,4 +70,26 @@ fun DocumentContentViewer(
modifier = modifier, modifier = modifier,
) )
} }
}
/** 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.Close
import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Description 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.Folder
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -45,11 +48,14 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -118,6 +124,7 @@ fun FolderListScreen(
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit, onOpenDocument: (String) -> Unit,
onBuildPdf: (List<String>) -> Unit, onBuildPdf: (List<String>) -> Unit,
onOpenScan: () -> Unit,
syncViewModel: SyncViewModel, syncViewModel: SyncViewModel,
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
viewModel: FolderListViewModel = hiltViewModel(), viewModel: FolderListViewModel = hiltViewModel(),
@@ -140,6 +147,8 @@ fun FolderListScreen(
var shareTarget by remember { mutableStateOf<ShareTarget?>(null) } var shareTarget by remember { mutableStateOf<ShareTarget?>(null) }
var showShareMenu by remember { mutableStateOf(false) } var showShareMenu by remember { mutableStateOf(false) }
var shareEnabled 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() } val snackbarHostState = remember { SnackbarHostState() }
// Le partage multi-sélection ne vaut que pour un dossier unique sélectionné, // 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) 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) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { padding -> ) { padding ->
Column( 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 val deleteReview = pendingDeleteReview
if (showDeleteWarning && deleteReview != null) { if (showDeleteWarning && deleteReview != null) {
DeleteWarningDialog( DeleteWarningDialog(
@@ -1108,4 +1164,51 @@ private fun formatSize(bytes: Long): String {
} }
val mb = bytes / (1_024f * 1_024f) val mb = bytes / (1_024f * 1_024f)
return String.format(Locale.getDefault(), "%.1f %s", mb, stringResource(R.string.unit_megabytes)) 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). */ /** Consomme une erreur transitoire de création (Snackbar). */
fun clearCreateError() { fun clearCreateError() {
_uiState.update { it.copy(createError = null) } _uiState.update { it.copy(createError = null) }
@@ -97,6 +97,7 @@ fun NavGraph(
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) }, onBuildPdf = { ids -> navController.navigate(Routes.pdfBuilder(ids)) },
onOpenScan = { navController.navigate(Routes.SCAN) },
syncViewModel = syncViewModel, syncViewModel = syncViewModel,
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
) )
@@ -21,6 +21,16 @@
<string name="new_folder_name_required">The folder name cannot be empty.</string> <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_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="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">Move</string>
<string name="move_files_title">Move to folder</string> <string name="move_files_title">Move to folder</string>
<string name="move_files_count">%1$d file(s)</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_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_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="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">Déplacer</string>
<string name="move_files_title">Déplacer vers un dossier</string> <string name="move_files_title">Déplacer vers un dossier</string>
<string name="move_files_count">%1$d fichier(s)</string> <string name="move_files_count">%1$d fichier(s)</string>