From 3a57532b40b79299588eeaba4ddd125a5a0e84e5 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 11 Sep 2026 13:10:40 +0200 Subject: [PATCH] working kotlin mobile app --- .../mobile/data/local/AppDatabase.kt | 9 +- .../mobile/data/local/dao/FileDao.kt | 26 ++ .../mobile/data/local/dao/FolderDao.kt | 3 + .../mobile/data/local/entity/FileEntity.kt | 62 +++++ .../mobile/data/local/migration/Migrations.kt | 31 ++- .../vaultdrop/mobile/data/remote/ApiClient.kt | 4 + .../mobile/data/remote/ApiService.kt | 10 + .../vaultdrop/mobile/data/remote/dto/Dtos.kt | 12 + .../mobile/data/repository/FileRepository.kt | 68 ++++++ .../data/repository/FolderRepository.kt | 6 + .../com/vaultdrop/mobile/di/DatabaseModule.kt | 4 + .../ui/folderdetail/FolderDetailScreen.kt | 225 ++++++++++++++++++ .../ui/folderdetail/FolderDetailViewModel.kt | 81 +++++++ .../mobile/ui/folderlist/FolderListScreen.kt | 8 +- .../mobile/ui/navigation/NavGraph.kt | 26 +- .../app/src/main/res/values-en/strings.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + mobile/services/distantStorage.ts | 0 18 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt delete mode 100644 mobile/services/distantStorage.ts diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt index d77e6ba..506454f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -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 } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt new file mode 100644 index 0000000..4a94fb7 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FileDao.kt @@ -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> + + @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) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt index 2833f57..5daff67 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt @@ -20,6 +20,9 @@ interface FolderDao { @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") suspend fun getRootFolders(): List + @Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId AND \"exists\" = 1 ORDER BY name ASC") + fun observeByParent(parentResourceId: String): Flow> + @Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC") suspend fun getByParent(parentResourceId: String): List diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt new file mode 100644 index 0000000..4988f28 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FileEntity.kt @@ -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" +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt index 5a7c8a2..0b40ee2 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -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 = 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 = arrayOf(MIGRATION_1_2, MIGRATION_2_3) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt index ae0e43f..3646b2f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt @@ -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 = unwrap({ apiService.listFolders() }) + suspend fun listFiles(folderId: String? = null, page: Int? = null, pageSize: Int? = null): List = + unwrap({ apiService.listFiles(folderId, page, pageSize) }) + suspend fun registerDevice(deviceId: String): String = unwrap({ apiService.registerDevice(DeviceRegistrationDto(deviceId)) }).deviceId diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt index f15b25d..552277a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt @@ -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>> + /** É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>> + /** Enregistrement idempotent du device — aucun token émis. */ @POST("devices") suspend fun registerDevice( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt index 7d086cd..582a048 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt @@ -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? = 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, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt new file mode 100644 index 0000000..d403cae --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -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> = + 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 + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt index c86217a..635f7f5 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -23,6 +23,10 @@ class FolderRepository @Inject constructor( fun observeRootFolders(): Flow> = folderDao.observeRootFolders() + /** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */ + fun observeSubFolders(parentResourceId: String): Flow> = + folderDao.observeByParent(parentResourceId) + suspend fun getRootFolders(): List = 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, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt index 21fcb77..e77e76d 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt @@ -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() } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt new file mode 100644 index 0000000..571167a --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -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, + files: List, + 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)) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt new file mode 100644 index 0000000..3498600 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailViewModel.kt @@ -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 = emptyList(), + val files: List = 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 = _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) } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index df8808f..2e9fe2f 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -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), ) { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index 17f88a0..f4effa2 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -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)) }, + ) } } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index a2f52df..7931844 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -7,8 +7,12 @@ No folders yet. Tap "Add a folder" to sync a folder. No files yet. Tap "Add a folder" to sync your files. Empty folder — next sync will refresh it. + B + KB + MB Could not load folders Refresh + Back Sign in diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 8d47d45..d7fdb10 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -8,8 +8,12 @@ Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier. Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers. Dossier vide — le prochain syncDevice l\'actualisera. + o + ko + Mo Impossible de charger les dossiers Actualiser + Retour Connexion diff --git a/mobile/services/distantStorage.ts b/mobile/services/distantStorage.ts deleted file mode 100644 index e69de29..0000000