add application migration
This commit is contained in:
@@ -11,11 +11,11 @@ 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.
|
* v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files.
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
|
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ interface FileDao {
|
|||||||
@Query("SELECT * FROM files WHERE uri = :uri LIMIT 1")
|
@Query("SELECT * FROM files WHERE uri = :uri LIMIT 1")
|
||||||
suspend fun getByUri(uri: String): FileEntity?
|
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<List<FileEntity>>
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
suspend fun upsert(file: FileEntity)
|
suspend fun upsert(file: FileEntity)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.room.PrimaryKey
|
|||||||
Index(value = ["resource_id"], unique = true),
|
Index(value = ["resource_id"], unique = true),
|
||||||
Index(value = ["uri"], unique = true),
|
Index(value = ["uri"], unique = true),
|
||||||
Index(value = ["folder_resource_id"]),
|
Index(value = ["folder_resource_id"]),
|
||||||
|
Index(value = ["category"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
data class FileEntity(
|
data class FileEntity(
|
||||||
@@ -46,6 +47,8 @@ data class FileEntity(
|
|||||||
val lastModified: Long? = null,
|
val lastModified: Long? = null,
|
||||||
@ColumnInfo(name = "owner_id")
|
@ColumnInfo(name = "owner_id")
|
||||||
val ownerId: String? = null,
|
val ownerId: String? = null,
|
||||||
|
@ColumnInfo(name = "category")
|
||||||
|
val category: String? = null,
|
||||||
@ColumnInfo(name = "sync_status")
|
@ColumnInfo(name = "sync_status")
|
||||||
val syncStatus: String = "local",
|
val syncStatus: String = "local",
|
||||||
@ColumnInfo(name = "added_at")
|
@ColumnInfo(name = "added_at")
|
||||||
|
|||||||
+12
-2
@@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
* v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir
|
* v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir
|
||||||
* du compte actif et les préférences utilisateur.
|
* du compte actif et les préférences utilisateur.
|
||||||
* v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud).
|
* v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud).
|
||||||
|
* v4 : ajout colonne `category` + index sur `files`.
|
||||||
*/
|
*/
|
||||||
object Migrations {
|
object Migrations {
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ object Migrations {
|
|||||||
`exists` INTEGER,
|
`exists` INTEGER,
|
||||||
`last_modified` INTEGER,
|
`last_modified` INTEGER,
|
||||||
`owner_id` TEXT,
|
`owner_id` TEXT,
|
||||||
`sync_status` TEXT NOT NULL DEFAULT 'local',
|
`sync_status` TEXT NOT NULL,
|
||||||
`added_at` INTEGER NOT NULL,
|
`added_at` INTEGER NOT NULL,
|
||||||
`updated_at` INTEGER NOT NULL
|
`updated_at` INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
@@ -56,5 +57,14 @@ object Migrations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val ALL: Array<Migration> = 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<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||||
}
|
}
|
||||||
+15
-1
@@ -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.ApiClient
|
||||||
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||||
import com.vaultdrop.mobile.domain.GenerateId
|
import com.vaultdrop.mobile.domain.GenerateId
|
||||||
|
import com.vaultdrop.mobile.domain.computeCategory
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -31,6 +32,13 @@ class FileRepository @Inject constructor(
|
|||||||
/** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */
|
/** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */
|
||||||
fun observeAllVisible(): Flow<List<FileEntity>> = fileDao.observeAllVisible()
|
fun observeAllVisible(): Flow<List<FileEntity>> = fileDao.observeAllVisible()
|
||||||
|
|
||||||
|
/** Recherche/filtrage par nom et catégorie (utilisé par SearchViewModel). */
|
||||||
|
fun searchFiles(query: String?, category: String?): Flow<List<FileEntity>> =
|
||||||
|
fileDao.searchWithFilters(
|
||||||
|
query = query?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
category = category,
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun getFile(resourceId: String): FileEntity? =
|
suspend fun getFile(resourceId: String): FileEntity? =
|
||||||
fileDao.getByResourceId(resourceId)
|
fileDao.getByResourceId(resourceId)
|
||||||
|
|
||||||
@@ -49,6 +57,8 @@ class FileRepository @Inject constructor(
|
|||||||
val existing = input.resourceId?.let { fileDao.getByResourceId(it) }
|
val existing = input.resourceId?.let { fileDao.getByResourceId(it) }
|
||||||
?: fileDao.getByUri(input.uri)
|
?: fileDao.getByUri(input.uri)
|
||||||
|
|
||||||
|
val category = computeCategory(input.mimeType, input.extension).dbValue
|
||||||
|
|
||||||
val entity = FileEntity(
|
val entity = FileEntity(
|
||||||
id = existing?.id ?: 0L,
|
id = existing?.id ?: 0L,
|
||||||
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
||||||
@@ -58,6 +68,7 @@ class FileRepository @Inject constructor(
|
|||||||
extension = input.extension ?: existing?.extension,
|
extension = input.extension ?: existing?.extension,
|
||||||
size = input.size,
|
size = input.size,
|
||||||
mimeType = input.mimeType,
|
mimeType = input.mimeType,
|
||||||
|
category = category,
|
||||||
exists = if (input.exists) 1 else 0,
|
exists = if (input.exists) 1 else 0,
|
||||||
lastModified = input.lastModified,
|
lastModified = input.lastModified,
|
||||||
ownerId = ownerId ?: existing?.ownerId,
|
ownerId = ownerId ?: existing?.ownerId,
|
||||||
@@ -88,15 +99,18 @@ class FileRepository @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun toEntity(dto: FileDto, folderResourceId: String, now: Long): FileEntity {
|
private suspend fun toEntity(dto: FileDto, folderResourceId: String, now: Long): FileEntity {
|
||||||
val existing = fileDao.getByResourceId(dto.id)
|
val existing = fileDao.getByResourceId(dto.id)
|
||||||
|
val extension = dto.name.substringAfterLast('.', "")
|
||||||
|
.takeIf { it.isNotEmpty() && it != dto.name }
|
||||||
return FileEntity(
|
return FileEntity(
|
||||||
id = existing?.id ?: 0L,
|
id = existing?.id ?: 0L,
|
||||||
resourceId = dto.id,
|
resourceId = dto.id,
|
||||||
uri = null,
|
uri = null,
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
folderResourceId = dto.folderId ?: folderResourceId,
|
folderResourceId = dto.folderId ?: folderResourceId,
|
||||||
extension = dto.name.substringAfterLast('.', "").takeIf { it.isNotEmpty() && it != dto.name },
|
extension = extension,
|
||||||
size = dto.size,
|
size = dto.size,
|
||||||
mimeType = dto.mimeType,
|
mimeType = dto.mimeType,
|
||||||
|
category = computeCategory(dto.mimeType, extension).dbValue,
|
||||||
exists = 1,
|
exists = 1,
|
||||||
lastModified = existing?.lastModified,
|
lastModified = existing?.lastModified,
|
||||||
ownerId = existing?.ownerId,
|
ownerId = existing?.ownerId,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -132,18 +132,25 @@ class SafScanner @Inject constructor(
|
|||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val docId = cursor.getString(iDoc)
|
val docId = cursor.getString(iDoc)
|
||||||
if (docId == dirDocId) continue // certains providers renvoient le dossier lui-même
|
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)
|
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) {
|
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||||
|
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString()
|
||||||
folders += ChildDir(docId, uri, name)
|
folders += ChildDir(docId, uri, name)
|
||||||
} else {
|
} 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(
|
files += FileNode(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
name = name,
|
name = name,
|
||||||
size = if (iSize >= 0 && !cursor.isNull(iSize)) cursor.getLong(iSize) else 0L,
|
size = size,
|
||||||
mimeType = mime,
|
mimeType = mime,
|
||||||
lastModified = if (iLast >= 0 && !cursor.isNull(iLast)) cursor.getLong(iLast) else null,
|
lastModified = if (iLast >= 0 && !cursor.isNull(iLast)) cursor.getLong(iLast) else null,
|
||||||
)
|
)
|
||||||
@@ -172,5 +179,14 @@ class SafScanner @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
/** Nombre de dossiers visités entre deux `yield()` (coopération inter-coroutines). */
|
/** Nombre de dossiers visités entre deux `yield()` (coopération inter-coroutines). */
|
||||||
const val YIELD_EVERY = 64
|
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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,7 @@ fun NavGraph() {
|
|||||||
SearchScreen(
|
SearchScreen(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
|
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
|
|||||||
@@ -1,26 +1,65 @@
|
|||||||
package com.vaultdrop.mobile.ui.search
|
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.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.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.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.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
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.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.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.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
selectedTab: NavTab,
|
selectedTab: NavTab,
|
||||||
onTabSelected: (NavTab) -> Unit,
|
onTabSelected: (NavTab) -> Unit,
|
||||||
|
onOpenDocument: (String) -> Unit,
|
||||||
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(title = { Text(stringResource(R.string.search)) })
|
TopAppBar(title = { Text(stringResource(R.string.search)) })
|
||||||
@@ -29,14 +68,196 @@ fun SearchScreen(
|
|||||||
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
|
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
SearchContent(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.search),
|
text = stringResource(R.string.search_empty),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
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))
|
||||||
|
}
|
||||||
@@ -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<String?>(null)
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
|
val uiState: StateFlow<SearchUiState> = 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<FileEntity> = emptyList(),
|
||||||
|
)
|
||||||
@@ -26,9 +26,22 @@
|
|||||||
<string name="nav_files">Files</string>
|
<string name="nav_files">Files</string>
|
||||||
<string name="nav_search">Search</string>
|
<string name="nav_search">Search</string>
|
||||||
<string name="nav_settings">Settings</string>
|
<string name="nav_settings">Settings</string>
|
||||||
<string name="search">Search</string>
|
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<string name="search">Search</string>
|
||||||
|
<string name="search_hint">Search for a document…</string>
|
||||||
|
<string name="search_empty">Type a search or pick a category</string>
|
||||||
|
<string name="search_no_results">No results</string>
|
||||||
|
<string name="category_all">All</string>
|
||||||
|
<string name="category_pdf">PDF</string>
|
||||||
|
<string name="category_office">Office</string>
|
||||||
|
<string name="category_image">Image</string>
|
||||||
|
<string name="category_text">Text</string>
|
||||||
|
<string name="category_video">Video</string>
|
||||||
|
<string name="category_audio">Audio</string>
|
||||||
|
<string name="category_other">Other</string>
|
||||||
|
|
||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
<string name="login_title">Sign in</string>
|
<string name="login_title">Sign in</string>
|
||||||
<string name="login_subtitle">Sign in to your account</string>
|
<string name="login_subtitle">Sign in to your account</string>
|
||||||
|
|||||||
@@ -27,9 +27,22 @@
|
|||||||
<string name="nav_files">Fichiers</string>
|
<string name="nav_files">Fichiers</string>
|
||||||
<string name="nav_search">Recherche</string>
|
<string name="nav_search">Recherche</string>
|
||||||
<string name="nav_settings">Réglages</string>
|
<string name="nav_settings">Réglages</string>
|
||||||
<string name="search">Recherche</string>
|
|
||||||
<string name="settings">Réglages</string>
|
<string name="settings">Réglages</string>
|
||||||
|
|
||||||
|
<!-- Recherche -->
|
||||||
|
<string name="search">Recherche</string>
|
||||||
|
<string name="search_hint">Rechercher un document…</string>
|
||||||
|
<string name="search_empty">Saisis une recherche ou choisis une catégorie</string>
|
||||||
|
<string name="search_no_results">Aucun résultat</string>
|
||||||
|
<string name="category_all">Tous</string>
|
||||||
|
<string name="category_pdf">PDF</string>
|
||||||
|
<string name="category_office">Bureautique</string>
|
||||||
|
<string name="category_image">Image</string>
|
||||||
|
<string name="category_text">Texte</string>
|
||||||
|
<string name="category_video">Vidéo</string>
|
||||||
|
<string name="category_audio">Audio</string>
|
||||||
|
<string name="category_other">Autre</string>
|
||||||
|
|
||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
<string name="login_title">Connexion</string>
|
<string name="login_title">Connexion</string>
|
||||||
<string name="login_subtitle">Connecte-toi à ton compte</string>
|
<string name="login_subtitle">Connecte-toi à ton compte</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user