scan document + creation de note
This commit is contained in:
@@ -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")
|
||||
|
||||
+9
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
+50
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+29
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user