From eddf40d80626f544a9f466915680154e230d8bc8 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 11 Sep 2026 18:21:10 +0200 Subject: [PATCH] add application migration --- .../mobile/data/local/AppDatabase.kt | 4 +- .../mobile/data/local/dao/FileDao.kt | 9 + .../mobile/data/local/entity/FileEntity.kt | 3 + .../mobile/data/local/migration/Migrations.kt | 14 +- .../mobile/data/repository/FileRepository.kt | 16 +- .../vaultdrop/mobile/domain/FileCategory.kt | 51 ++++ .../mobile/features/sync/SafScanner.kt | 24 +- .../mobile/ui/navigation/NavGraph.kt | 1 + .../mobile/ui/search/SearchScreen.kt | 227 +++++++++++++++++- .../mobile/ui/search/SearchViewModel.kt | 63 +++++ .../app/src/main/res/values-en/strings.xml | 15 +- .../app/src/main/res/values/strings.xml | 15 +- 12 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/FileCategory.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt 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 506454f..edc9ca0 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 @@ -11,11 +11,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. + * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files. */ @Database( entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class], - version = 3, + version = 4, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt index 67a4899..d50cc60 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -24,6 +24,15 @@ interface FileDao { @Query("SELECT * FROM files WHERE uri = :uri LIMIT 1") suspend fun getByUri(uri: String): FileEntity? + @Query(""" + SELECT * FROM files + WHERE "exists" = 1 + AND (:query IS NULL OR name LIKE '%' || :query || '%') + AND (:category IS NULL OR category = :category) + ORDER BY name ASC + """) + fun searchWithFilters(query: String?, category: String?): Flow> + @Upsert suspend fun upsert(file: FileEntity) 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 4988f28..4547afc 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 @@ -21,6 +21,7 @@ import androidx.room.PrimaryKey Index(value = ["resource_id"], unique = true), Index(value = ["uri"], unique = true), Index(value = ["folder_resource_id"]), + Index(value = ["category"]), ], ) data class FileEntity( @@ -46,6 +47,8 @@ data class FileEntity( val lastModified: Long? = null, @ColumnInfo(name = "owner_id") val ownerId: String? = null, + @ColumnInfo(name = "category") + val category: String? = null, @ColumnInfo(name = "sync_status") val syncStatus: String = "local", @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 0b40ee2..2ac47f3 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 @@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase * v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir * du compte actif et les préférences utilisateur. * v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud). + * v4 : ajout colonne `category` + index sur `files`. */ object Migrations { @@ -44,7 +45,7 @@ object Migrations { `exists` INTEGER, `last_modified` INTEGER, `owner_id` TEXT, - `sync_status` TEXT NOT NULL DEFAULT 'local', + `sync_status` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL ) @@ -56,5 +57,14 @@ object Migrations { } } - val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3) + private val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Pas de `DEFAULT NULL` explicite : Room s'attend à l'absence de défaut + // (une colonne TEXT nullable a déjà NULL comme défaut implicite). + db.execSQL("ALTER TABLE `files` ADD COLUMN `category` TEXT") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_files_category` ON `files` (`category`)") + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) } \ 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 44fb3b2..9b2cc98 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 @@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.entity.FileStatus import com.vaultdrop.mobile.data.remote.ApiClient import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.domain.GenerateId +import com.vaultdrop.mobile.domain.computeCategory import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -31,6 +32,13 @@ class FileRepository @Inject constructor( /** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */ fun observeAllVisible(): Flow> = fileDao.observeAllVisible() + /** Recherche/filtrage par nom et catégorie (utilisé par SearchViewModel). */ + fun searchFiles(query: String?, category: String?): Flow> = + fileDao.searchWithFilters( + query = query?.trim()?.takeIf { it.isNotEmpty() }, + category = category, + ) + suspend fun getFile(resourceId: String): FileEntity? = fileDao.getByResourceId(resourceId) @@ -49,6 +57,8 @@ class FileRepository @Inject constructor( val existing = input.resourceId?.let { fileDao.getByResourceId(it) } ?: fileDao.getByUri(input.uri) + val category = computeCategory(input.mimeType, input.extension).dbValue + val entity = FileEntity( id = existing?.id ?: 0L, resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(), @@ -58,6 +68,7 @@ class FileRepository @Inject constructor( extension = input.extension ?: existing?.extension, size = input.size, mimeType = input.mimeType, + category = category, exists = if (input.exists) 1 else 0, lastModified = input.lastModified, ownerId = ownerId ?: existing?.ownerId, @@ -88,15 +99,18 @@ class FileRepository @Inject constructor( private suspend fun toEntity(dto: FileDto, folderResourceId: String, now: Long): FileEntity { val existing = fileDao.getByResourceId(dto.id) + val extension = dto.name.substringAfterLast('.', "") + .takeIf { it.isNotEmpty() && it != dto.name } return FileEntity( id = existing?.id ?: 0L, resourceId = dto.id, uri = null, name = dto.name, folderResourceId = dto.folderId ?: folderResourceId, - extension = dto.name.substringAfterLast('.', "").takeIf { it.isNotEmpty() && it != dto.name }, + extension = extension, size = dto.size, mimeType = dto.mimeType, + category = computeCategory(dto.mimeType, extension).dbValue, exists = 1, lastModified = existing?.lastModified, ownerId = existing?.ownerId, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/FileCategory.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/FileCategory.kt new file mode 100644 index 0000000..2d02750 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/FileCategory.kt @@ -0,0 +1,51 @@ +package com.vaultdrop.mobile.domain + +enum class FileCategory { + PDF, + OFFICE, + IMAGE, + TEXT, + VIDEO, + AUDIO, + OTHER; + + val dbValue: String get() = name +} + +fun computeCategory(mimeType: String?, extension: String?): FileCategory { + val mime = mimeType?.lowercase().orEmpty() + val ext = extension?.lowercase().orEmpty() + + return when { + mime == "application/pdf" -> FileCategory.PDF + mime.startsWith("image/") -> FileCategory.IMAGE + mime.startsWith("video/") -> FileCategory.VIDEO + mime.startsWith("audio/") -> FileCategory.AUDIO + mime.startsWith("text/") || ext in TEXT_EXTENSIONS -> FileCategory.TEXT + mime in OFFICE_MIMES || ext in OFFICE_EXTENSIONS -> FileCategory.OFFICE + else -> FileCategory.OTHER + } +} + +private val OFFICE_MIMES = setOf( + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.oasis.opendocument.text", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.oasis.opendocument.presentation", +) + +private val OFFICE_EXTENSIONS = setOf( + "doc", "docx", "odt", "rtf", + "xls", "xlsx", "ods", "csv", + "ppt", "pptx", "odp", +) + +private val TEXT_EXTENSIONS = setOf( + "txt", "md", "html", "xml", "json", "css", "js", "kt", "py", "java", + "log", "ini", "cfg", "yaml", "yml", "toml", +) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt index 190801f..7ac5cf7 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SafScanner.kt @@ -132,18 +132,25 @@ class SafScanner @Inject constructor( while (cursor.moveToNext()) { val docId = cursor.getString(iDoc) if (docId == dirDocId) continue // certains providers renvoient le dossier lui-même - val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString() - val mime = if (iMime >= 0) cursor.getString(iMime) else null val name = if (iName >= 0) cursor.getString(iName) - else docId.substringAfterLast('/') + else docId.substringAfterLast('/') + val mime = if (iMime >= 0) cursor.getString(iMime) else null + + if (name.startsWith(".")) continue if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString() folders += ChildDir(docId, uri, name) } else { + val size = if (iSize >= 0 && !cursor.isNull(iSize)) cursor.getLong(iSize) else 0L + if (size == 0L) continue + if (KNOWN_NOISE_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }) continue + + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString() files += FileNode( uri = uri, name = name, - size = if (iSize >= 0 && !cursor.isNull(iSize)) cursor.getLong(iSize) else 0L, + size = size, mimeType = mime, lastModified = if (iLast >= 0 && !cursor.isNull(iLast)) cursor.getLong(iLast) else null, ) @@ -172,5 +179,14 @@ class SafScanner @Inject constructor( private companion object { /** Nombre de dossiers visités entre deux `yield()` (coopération inter-coroutines). */ const val YIELD_EVERY = 64 + + /** Extensions de bruit système / OS / téléchargements incomplets à ignorer. */ + val KNOWN_NOISE_EXTENSIONS = setOf( + ".tmp", ".log", ".bak", ".dat", ".db", ".db-wal", ".db-shm", + ".nomedia", ".thumbnails", + ".apk", ".dex", ".odex", + ".part", ".crdownload", + ".DS_Store", + ) } } \ No newline at end of file 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 ab4326c..8ae9cad 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 @@ -65,6 +65,7 @@ fun NavGraph() { SearchScreen( selectedTab = selectedTab, onTabSelected = onTabSelected, + onOpenDocument = { id -> navController.navigate(Routes.document(id)) }, ) } composable(Routes.SETTINGS) { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt index edbd8d9..de13043 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchScreen.kt @@ -1,26 +1,65 @@ package com.vaultdrop.mobile.ui.search +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue 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.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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.data.local.entity.FileEntity +import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, + onOpenDocument: (String) -> Unit, + viewModel: SearchViewModel = hiltViewModel(), ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.search)) }) @@ -29,14 +68,196 @@ fun SearchScreen( FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) }, ) { padding -> - Box( - modifier = Modifier.fillMaxSize(), + SearchContent( + uiState = uiState, + onQueryChange = viewModel::onQueryChange, + onCategorySelect = viewModel::onCategorySelect, + onOpenDocument = onOpenDocument, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +private fun SearchContent( + uiState: SearchUiState, + onQueryChange: (String) -> Unit, + onCategorySelect: (String?) -> Unit, + onOpenDocument: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = uiState.query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.search_hint)) }, + singleLine = true, + ) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(key = "all") { + CategoryChip( + label = stringResource(R.string.category_all), + selected = uiState.selectedCategory == null, + onClick = { onCategorySelect(null) }, + ) + } + items(FileCategory.entries, key = { it.name }) { category -> + CategoryChip( + label = categoryLabel(category), + selected = uiState.selectedCategory == category.dbValue, + onClick = { onCategorySelect(category.dbValue) }, + ) + } + } + + SearchResults( + uiState = uiState, + onOpenDocument = onOpenDocument, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun CategoryChip( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label) }, + colors = FilterChipDefaults.filterChipColors( + containerColor = Color(0xFFF7F8FA), + ), + ) +} + +/** Libellé français de la catégorie (store = enum.name, affichage = label localisé). */ +@Composable +private fun categoryLabel(category: FileCategory): String = when (category) { + FileCategory.PDF -> stringResource(R.string.category_pdf) + FileCategory.OFFICE -> stringResource(R.string.category_office) + FileCategory.IMAGE -> stringResource(R.string.category_image) + FileCategory.TEXT -> stringResource(R.string.category_text) + FileCategory.VIDEO -> stringResource(R.string.category_video) + FileCategory.AUDIO -> stringResource(R.string.category_audio) + FileCategory.OTHER -> stringResource(R.string.category_other) +} + +@Composable +private fun SearchResults( + uiState: SearchUiState, + onOpenDocument: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val hasQueryOrCategory = uiState.query.isNotBlank() || uiState.selectedCategory != null + + when { + !hasQueryOrCategory -> Box( + modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( - text = stringResource(R.string.search), + text = stringResource(R.string.search_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } + + uiState.results.isEmpty() -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.search_no_results), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(uiState.results, key = { it.resourceId }) { file -> + SearchResultCard(file = file, onClick = { onOpenDocument(file.resourceId) }) + } + } + } +} + +@Composable +private fun SearchResultCard(file: FileEntity, onClick: () -> Unit, modifier: Modifier = Modifier) { + Card( + onClick = onClick, + colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, Color(0xFFEAEAEA)), + shape = RoundedCornerShape(10.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = file.extension?.let { it.uppercase(Locale.getDefault()) } ?: formatSize(file.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Text( + text = formatSize(file.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } + +@Composable +private fun formatSize(bytes: Long): String { + if (bytes < 1_024) { + return "$bytes ${stringResource(R.string.unit_bytes)}" + } + if (bytes < 1_024 * 1_024) { + val kb = bytes / 1_024f + return String.format(Locale.getDefault(), "%.1f %s", kb, stringResource(R.string.unit_kilobytes)) + } + val mb = bytes / (1_024f * 1_024f) + return String.format(Locale.getDefault(), "%.1f %s", mb, stringResource(R.string.unit_megabytes)) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt new file mode 100644 index 0000000..d2c7f85 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/search/SearchViewModel.kt @@ -0,0 +1,63 @@ +package com.vaultdrop.mobile.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.repository.FileRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val fileRepository: FileRepository, +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + private val _selectedCategory = MutableStateFlow(null) + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val uiState: StateFlow = combine( + _searchQuery.debounce(SEARCH_DEBOUNCE_MS), + _selectedCategory, + ) { query, category -> CategoryQuery(query, category) } + .flatMapLatest { (query, category) -> + fileRepository.searchFiles(query, category) + .map { files -> SearchUiState(query, category, files) } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = SearchUiState(), + ) + + fun onQueryChange(query: String) { + _searchQuery.value = query + } + + fun onCategorySelect(category: String?) { + _selectedCategory.value = category + } + + private data class CategoryQuery(val query: String, val category: String?) + + companion object { + private const val SEARCH_DEBOUNCE_MS = 300L + private const val STOP_TIMEOUT_MS = 5_000L + } +} + +data class SearchUiState( + val query: String = "", + val selectedCategory: String? = null, + val results: List = emptyList(), +) \ No newline at end of file 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 b673d2a..327c00d 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -26,9 +26,22 @@ Files Search Settings - Search Settings + + Search + Search for a document… + Type a search or pick a category + No results + All + PDF + Office + Image + Text + Video + Audio + Other + Sign in Sign in to your account diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index d604149..eb28665 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -27,9 +27,22 @@ Fichiers Recherche Réglages - Recherche Réglages + + Recherche + Rechercher un document… + Saisis une recherche ou choisis une catégorie + Aucun résultat + Tous + PDF + Bureautique + Image + Texte + Vidéo + Audio + Autre + Connexion Connecte-toi à ton compte