working kotlin mobile app

This commit is contained in:
m
2026-09-11 13:10:40 +02:00
parent b2d8f3702c
commit 3a57532b40
18 changed files with 576 additions and 7 deletions
@@ -2,22 +2,25 @@ package com.vaultdrop.mobile.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
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.
* v1: folders ; v2: user_preferences ; v3: files.
*/
@Database(
entities = [FolderEntity::class, UserPreferenceEntity::class],
version = 2,
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
version = 3,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun folderDao(): FolderDao
abstract fun userPreferenceDao(): UserPreferenceDao
abstract fun fileDao(): FileDao
}
@@ -0,0 +1,26 @@
package com.vaultdrop.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.vaultdrop.mobile.data.local.entity.FileEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface FileDao {
@Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>>
@Query("SELECT * FROM files WHERE resource_id = :resourceId LIMIT 1")
suspend fun getByResourceId(resourceId: String): FileEntity?
@Query("SELECT * FROM files WHERE uri = :uri LIMIT 1")
suspend fun getByUri(uri: String): FileEntity?
@Upsert
suspend fun upsert(file: FileEntity)
@Query("DELETE FROM files WHERE resource_id = :resourceId")
suspend fun remove(resourceId: String)
}
@@ -20,6 +20,9 @@ interface FolderDao {
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
suspend fun getRootFolders(): List<FolderEntity>
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId AND \"exists\" = 1 ORDER BY name ASC")
fun observeByParent(parentResourceId: String): Flow<List<FolderEntity>>
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC")
suspend fun getByParent(parentResourceId: String): List<FolderEntity>
@@ -0,0 +1,62 @@
package com.vaultdrop.mobile.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Miroir de `FileRow` (mobile/services/db/types.ts).
*
* `uri` NULL = fichier cloud-only (pas de copie physique locale) ; l'index
* unique est posé sur la colonne directement (SQLite traite les NULL comme
* distincts — équivalent de l'index partiel JS `WHERE uri IS NOT NULL`).
*
* `folder_resource_id` = resource_id du dossier hôte (jamais NULL côté SQLite
* JS ; les fichiers racine sont rares et hors scope V1).
*/
@Entity(
tableName = "files",
indices = [
Index(value = ["resource_id"], unique = true),
Index(value = ["uri"], unique = true),
Index(value = ["folder_resource_id"]),
],
)
data class FileEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
@ColumnInfo(name = "resource_id")
val resourceId: String,
@ColumnInfo(name = "uri")
val uri: String? = null,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "folder_resource_id")
val folderResourceId: String,
@ColumnInfo(name = "extension")
val extension: String? = null,
@ColumnInfo(name = "size")
val size: Long,
@ColumnInfo(name = "mime_type")
val mimeType: String? = null,
@ColumnInfo(name = "exists")
val exists: Int? = null,
@ColumnInfo(name = "last_modified")
val lastModified: Long? = null,
@ColumnInfo(name = "owner_id")
val ownerId: String? = null,
@ColumnInfo(name = "sync_status")
val syncStatus: String = "local",
@ColumnInfo(name = "added_at")
val addedAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)
/** Placement d'un fichier — même convention que `FolderStatus`. */
object FileStatus {
const val LOCAL = "local"
const val CLOUD = "cloud"
const val LOCAL_CLOUD = "local-cloud"
}
@@ -9,6 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* v1 : table `folders` (créée par Room à partir de l'entité).
* 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).
*/
object Migrations {
@@ -27,5 +28,33 @@ object Migrations {
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2)
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `files` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`resource_id` TEXT NOT NULL,
`uri` TEXT,
`name` TEXT NOT NULL,
`folder_resource_id` TEXT NOT NULL,
`extension` TEXT,
`size` INTEGER NOT NULL,
`mime_type` TEXT,
`exists` INTEGER,
`last_modified` INTEGER,
`owner_id` TEXT,
`sync_status` TEXT NOT NULL DEFAULT 'local',
`added_at` INTEGER NOT NULL,
`updated_at` INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_files_resource_id` ON `files` (`resource_id`)")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_files_uri` ON `files` (`uri`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_files_folder_resource_id` ON `files` (`folder_resource_id`)")
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
}
@@ -5,6 +5,7 @@ import com.squareup.moshi.Moshi
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.ApiErrorEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -48,6 +49,9 @@ class ApiClient @Inject constructor(
suspend fun listFolders(): List<FolderDto> = unwrap({ apiService.listFolders() })
suspend fun listFiles(folderId: String? = null, page: Int? = null, pageSize: Int? = null): List<FileDto> =
unwrap({ apiService.listFiles(folderId, page, pageSize) })
suspend fun registerDevice(deviceId: String): String =
unwrap({ apiService.registerDevice(DeviceRegistrationDto(deviceId)) }).deviceId
@@ -2,6 +2,7 @@ package com.vaultdrop.mobile.data.remote
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
@@ -9,6 +10,7 @@ import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
/**
* Contrat HTTP V1 — copie de `mobile/api/client.ts` (le client mobile est la
@@ -20,6 +22,14 @@ interface ApiService {
@GET("files/folders")
suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>>
/** Équivalent de `GET /files` (client.ts). Paginé (`meta`). */
@GET("files")
suspend fun listFiles(
@Query("folderId") folderId: String?,
@Query("page") page: Int?,
@Query("pageSize") pageSize: Int?,
): Response<ApiEnvelope<List<FileDto>>>
/** Enregistrement idempotent du device — aucun token émis. */
@POST("devices")
suspend fun registerDevice(
@@ -37,6 +37,18 @@ data class ApiErrorEnvelope(
@Json(name = "error") val error: ApiErrorBody? = null,
)
/** Miroir de `api/types.ts` — `FileDto`. */
data class FileDto(
@Json(name = "id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "size") val size: Long,
@Json(name = "mimeType") val mimeType: String? = null,
@Json(name = "folderId") val folderId: String? = null,
@Json(name = "tags") val tags: List<String>? = null,
@Json(name = "createdAt") val createdAt: String? = null,
@Json(name = "updatedAt") val updatedAt: String? = null,
)
/** Miroir de `api/types.ts` — `FolderDto`. */
data class FolderDto(
@Json(name = "id") val id: String,
@@ -0,0 +1,68 @@
package com.vaultdrop.mobile.data.repository
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FileStatus
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.dto.FileDto
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Miroir de `services/db/repositories/files.ts`. Local-first : l'UI observe
* Room ; le réseau rafraîchit les fichiers cloud d'un dossier (`GET /files`).
*
* Les fichiers renvoyés par le serveur sont cloud-only (uri = NULL) :
* aucune copie physique locale, mirror de `saveFile(..., syncStatus='cloud')`.
*/
@Singleton
class FileRepository @Inject constructor(
private val fileDao: FileDao,
private val apiClient: ApiClient,
) {
/** Fichiers visibles du dossier, locaux + cloud — source de l'UI. */
fun observeFiles(folderResourceId: String): Flow<List<FileEntity>> =
fileDao.observeByFolder(folderResourceId)
suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId)
/**
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
* chaque fichier. Ne supprime jamais de lignes locales.
*/
suspend fun refreshFromServer(folderResourceId: String) {
val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE)
if (files.isEmpty()) return
val now = System.currentTimeMillis()
files.forEach { dto ->
fileDao.upsert(toEntity(dto, folderResourceId, now))
}
}
private suspend fun toEntity(dto: FileDto, folderResourceId: String, now: Long): FileEntity {
val existing = fileDao.getByResourceId(dto.id)
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 },
size = dto.size,
mimeType = dto.mimeType,
exists = 1,
lastModified = existing?.lastModified,
ownerId = existing?.ownerId,
syncStatus = existing?.syncStatus ?: FileStatus.CLOUD,
addedAt = existing?.addedAt ?: now,
updatedAt = now,
)
}
companion object {
private const val PAGE_SIZE = 50
}
}
@@ -23,6 +23,10 @@ class FolderRepository @Inject constructor(
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
/** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */
fun observeSubFolders(parentResourceId: String): Flow<List<FolderEntity>> =
folderDao.observeByParent(parentResourceId)
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
suspend fun getFolder(resourceId: String): FolderEntity? =
@@ -43,6 +47,7 @@ class FolderRepository @Inject constructor(
private suspend fun toEntity(dto: FolderDto, now: Long): FolderEntity {
val existing = folderDao.getByResourceId(dto.id)
return FolderEntity(
id = existing?.id ?: 0L,
resourceId = dto.id,
uri = null,
name = dto.name,
@@ -68,6 +73,7 @@ class FolderRepository @Inject constructor(
?: input.uri?.let { folderDao.getByUri(it) }
val entity = FolderEntity(
id = existing?.id ?: 0L,
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
uri = input.uri,
name = input.name,
@@ -3,6 +3,7 @@ package com.vaultdrop.mobile.di
import android.content.Context
import androidx.room.Room
import com.vaultdrop.mobile.data.local.AppDatabase
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.migration.Migrations
@@ -29,4 +30,7 @@ object DatabaseModule {
@Provides
fun provideUserPreferenceDao(db: AppDatabase): UserPreferenceDao = db.userPreferenceDao()
@Provides
fun provideFileDao(db: AppDatabase): FileDao = db.fileDao()
}
@@ -0,0 +1,225 @@
package com.vaultdrop.mobile.ui.folderdetail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.data.local.entity.FolderEntity
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FolderDetailScreen(
folderResourceId: String,
onBack: () -> Unit,
onOpenFolder: (String) -> Unit,
viewModel: FolderDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.folderMissing) {
if (uiState.folderMissing) onBack()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.folder?.name ?: stringResource(R.string.folder)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
IconButton(onClick = viewModel::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
}
},
)
},
) { padding ->
FolderDetailContent(
subFolders = uiState.subFolders,
files = uiState.files,
isRefreshing = uiState.isRefreshing,
error = uiState.error,
onOpenFolder = onOpenFolder,
modifier = Modifier.padding(padding),
)
}
}
@Composable
private fun FolderDetailContent(
subFolders: List<FolderEntity>,
files: List<FileEntity>,
isRefreshing: Boolean,
error: String?,
onOpenFolder: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val empty = subFolders.isEmpty() && files.isEmpty() && !isRefreshing
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(subFolders, key = { it.resourceId }) { folder ->
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
}
items(files, key = { it.resourceId }) { file ->
FileRow(file)
}
when {
empty -> {
item(key = "empty") {
Text(
text = stringResource(R.string.empty_folder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
)
}
}
}
if (error != null) {
item(key = "error") {
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
}
}
}
}
@Composable
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.folder),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun FileRow(file: FileEntity) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
)
Text(
text = formatSize(file.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** Miroir de `formatSize` (app/folder/[id].tsx). */
@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,81 @@
package com.vaultdrop.mobile.ui.folderdetail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.auth.TokenProvider
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class FolderDetailUiState(
val folder: FolderEntity? = null,
val folderMissing: Boolean = false,
val subFolders: List<FolderEntity> = emptyList(),
val files: List<FileEntity> = emptyList(),
val isRefreshing: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class FolderDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val folderRepository: FolderRepository,
private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider,
) : ViewModel() {
private val folderResourceId: String =
checkNotNull(savedStateHandle["folderResourceId"])
private val _uiState = MutableStateFlow(FolderDetailUiState())
val uiState: StateFlow<FolderDetailUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val folder = folderRepository.getFolder(folderResourceId)
if (folder == null) {
// Écran orphelin (dossier supprimé/réconcilié) → le composable
// navigate en arrière.
_uiState.value = _uiState.value.copy(folderMissing = true)
return@launch
}
_uiState.value = _uiState.value.copy(folder = folder)
}
viewModelScope.launch {
folderRepository.observeSubFolders(folderResourceId).collect { subFolders ->
_uiState.update { it.copy(subFolders = subFolders) }
}
}
viewModelScope.launch {
fileRepository.observeFiles(folderResourceId).collect { files ->
_uiState.update { it.copy(files = files) }
}
}
refresh()
}
fun refresh() {
viewModelScope.launch {
// Sans token → mode local : les fichiers n'existent que localement.
if (tokenProvider.current == null) return@launch
_uiState.update { it.copy(isRefreshing = true, error = null) }
runCatching { fileRepository.refreshFromServer(folderResourceId) }
.onFailure { e ->
_uiState.update { it.copy(error = e.message ?: "Erreur réseau") }
}
_uiState.update { it.copy(isRefreshing = false) }
}
}
}
@@ -40,6 +40,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FolderListScreen(
onOpenFolder: (String) -> Unit,
viewModel: FolderListViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -58,6 +59,7 @@ fun FolderListScreen(
) { padding ->
FolderListContent(
uiState = uiState,
onOpenFolder = onOpenFolder,
modifier = Modifier.padding(padding),
)
}
@@ -66,6 +68,7 @@ fun FolderListScreen(
@Composable
private fun FolderListContent(
uiState: FolderListUiState,
onOpenFolder: (String) -> Unit,
modifier: Modifier = Modifier,
) {
when {
@@ -103,7 +106,7 @@ private fun FolderListContent(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(uiState.folders, key = { it.resourceId }) { folder ->
FolderRow(folder)
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
}
}
}
@@ -111,8 +114,9 @@ private fun FolderListContent(
}
@Composable
private fun FolderRow(folder: FolderEntity) {
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
@@ -1,13 +1,20 @@
package com.vaultdrop.mobile.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
object Routes {
const val FILES = "files"
const val FOLDER_DETAIL = "folder/{folderResourceId}"
const val ARG_FOLDER = "folderResourceId"
fun folder(folderResourceId: String): String = "folder/$folderResourceId"
}
@Composable
@@ -18,7 +25,24 @@ fun NavGraph() {
startDestination = Routes.FILES,
) {
composable(Routes.FILES) {
FolderListScreen()
FolderListScreen(
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
)
}
composable(
route = Routes.FOLDER_DETAIL,
arguments = listOf(
navArgument(Routes.ARG_FOLDER) { type = NavType.StringType },
),
) { backStackEntry ->
val folderId = checkNotNull(
backStackEntry.arguments?.getString(Routes.ARG_FOLDER),
)
FolderDetailScreen(
folderResourceId = folderId,
onBack = { navController.popBackStack() },
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
)
}
}
}
@@ -7,8 +7,12 @@
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
<string name="empty_folder">Empty folder — next sync will refresh it.</string>
<string name="unit_bytes">B</string>
<string name="unit_kilobytes">KB</string>
<string name="unit_megabytes">MB</string>
<string name="error_loading">Could not load folders</string>
<string name="refresh">Refresh</string>
<string name="back">Back</string>
<!-- Login -->
<string name="login_title">Sign in</string>
@@ -8,8 +8,12 @@
<string name="no_folders_yet">Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.</string>
<string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>
<string name="empty_folder">Dossier vide — le prochain syncDevice l\'actualisera.</string>
<string name="unit_bytes">o</string>
<string name="unit_kilobytes">ko</string>
<string name="unit_megabytes">Mo</string>
<string name="error_loading">Impossible de charger les dossiers</string>
<string name="refresh">Actualiser</string>
<string name="back">Retour</string>
<!-- Login -->
<string name="login_title">Connexion</string>
View File