working kotlin mobile app
This commit is contained in:
@@ -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"
|
||||
}
|
||||
+30
-1
@@ -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,
|
||||
|
||||
+68
@@ -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
|
||||
}
|
||||
}
|
||||
+6
@@ -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()
|
||||
}
|
||||
+225
@@ -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))
|
||||
}
|
||||
+81
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user