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).
|
||||
* 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() {
|
||||
|
||||
@@ -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<List<FileEntity>>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(file: FileEntity)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+12
-2
@@ -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<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.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<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? =
|
||||
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,
|
||||
|
||||
@@ -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()) {
|
||||
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('/')
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ fun NavGraph() {
|
||||
SearchScreen(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = onTabSelected,
|
||||
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SETTINGS) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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_search">Search</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="search">Search</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 -->
|
||||
<string name="login_title">Sign in</string>
|
||||
<string name="login_subtitle">Sign in to your account</string>
|
||||
|
||||
@@ -27,9 +27,22 @@
|
||||
<string name="nav_files">Fichiers</string>
|
||||
<string name="nav_search">Recherche</string>
|
||||
<string name="nav_settings">Réglages</string>
|
||||
<string name="search">Recherche</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 -->
|
||||
<string name="login_title">Connexion</string>
|
||||
<string name="login_subtitle">Connecte-toi à ton compte</string>
|
||||
|
||||
Reference in New Issue
Block a user