diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt index 961cf69..fea0b9c 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -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() { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt index 1c345b6..9dd1987 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt @@ -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") diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt index b8120d5..f7b6dcb 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -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 = 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, ) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index 9ad9a80..007c13d 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -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 = fileDao.getAll() /** diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt index 5e42e39..b7c3bc4 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt @@ -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 @@ -54,4 +70,26 @@ fun DocumentContentViewer( 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, + ) + } + } + } + } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index 3437807..b520b2d 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -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) -> Unit, + onOpenScan: () -> Unit, syncViewModel: SyncViewModel, connectionStatusViewModel: ConnectionStatusViewModel, viewModel: FolderListViewModel = hiltViewModel(), @@ -140,6 +147,8 @@ fun FolderListScreen( var shareTarget by remember { mutableStateOf(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( @@ -1108,4 +1164,51 @@ 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)) + } + }, + ) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index a23de57..4ad36f1 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -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) } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index 6899992..e44c340 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -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, ) diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index 6dafd72..0d23980 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -21,6 +21,16 @@ The folder name cannot be empty. This folder is cloud-only — import a physical folder first to create subfolders inside it. Could not create the folder. + Scan a document + Add a note + Add + New note + Title + e.g. shopping list + Note text + Cancel + Create + The note title cannot be empty. Move Move to folder %1$d file(s) diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 6e11ac5..7f107a4 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -39,6 +39,16 @@ Le nom du dossier ne peut pas être vide. Ce dossier est disponible uniquement sur le cloud — importe d\'abord un dossier physique pour y créer des sous-dossiers. Impossible de créer le dossier. + Scanner un document + Ajouter une note + Ajouter + Nouvelle note + Titre + ex. liste de courses + Texte de la note + Annuler + Créer + Le titre de la note ne peut pas être vide. Déplacer Déplacer vers un dossier %1$d fichier(s)