From bca29ed26f1ea3a7e5a99360603b722c9254a19c Mon Sep 17 00:00:00 2001 From: m Date: Fri, 11 Sep 2026 16:56:49 +0200 Subject: [PATCH] filedao --- .../mobile/data/local/dao/FileDao.kt | 3 + .../mobile/data/repository/FileRepository.kt | 3 + .../mobile/ui/folderlist/FolderListScreen.kt | 193 ++++++++++-------- .../mobile/ui/folderlist/FolderListUiState.kt | 19 +- .../ui/folderlist/FolderListViewModel.kt | 48 ++++- .../mobile/ui/navigation/NavGraph.kt | 1 - 6 files changed, 177 insertions(+), 90 deletions(-) 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 index 1caf6dc..67a4899 100644 --- 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 @@ -15,6 +15,9 @@ 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 \"exists\" = 1 ORDER BY added_at DESC, name ASC") + fun observeAllVisible(): Flow> + @Query("SELECT * FROM files WHERE resource_id = :resourceId LIMIT 1") suspend fun getByResourceId(resourceId: String): FileEntity? 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 index 92319e5..44fb3b2 100644 --- 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 @@ -28,6 +28,9 @@ class FileRepository @Inject constructor( fun observeFiles(folderResourceId: String): Flow> = fileDao.observeByFolder(folderResourceId) + /** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */ + fun observeAllVisible(): Flow> = fileDao.observeAllVisible() + suspend fun getFile(resourceId: String): FileEntity? = fileDao.getByResourceId(resourceId) 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 1af531c..51ee412 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 @@ -7,27 +7,28 @@ import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +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.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -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.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -38,8 +39,8 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -49,16 +50,16 @@ 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.FolderEntity +import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun FolderListScreen( selectedTab: NavTab, onTabSelected: (NavTab) -> Unit, - onOpenFolder: (String) -> Unit, viewModel: FolderListViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -99,7 +100,6 @@ fun FolderListScreen( ) { padding -> FolderListContent( uiState = uiState, - onOpenFolder = onOpenFolder, onAddFolder = { pickFolderLauncher.launch(null) }, modifier = Modifier.padding(padding), ) @@ -124,82 +124,80 @@ private fun Uri.displayName(context: Context): String? = runCatching { @Composable private fun FolderListContent( uiState: FolderListUiState, - onOpenFolder: (String) -> Unit, onAddFolder: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxSize()) { - Button( - onClick = onAddFolder, - enabled = !uiState.isScanning, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Icon(Icons.Filled.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.add_folder)) + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item(key = "actions") { + Button( + onClick = onAddFolder, + enabled = !uiState.isScanning, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.add_folder)) + } } if (uiState.isScanning) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) + item(key = "scanning") { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } } uiState.error?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) + item(key = "error") { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) + } } - when { - uiState.folders.isEmpty() && uiState.isRefreshing -> { - Box( + if (uiState.sections.isEmpty() && !uiState.isScanning) { + item(key = "empty") { + Text( + text = stringResource(R.string.no_files_yet), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - uiState.folders.isEmpty() -> { - Box( - modifier = Modifier - .weight(1f) .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.no_folders_yet), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + .padding(vertical = 32.dp), + ) } + } - else -> { - LazyColumn( + uiState.sections.forEach { section -> + item(key = "header-${section.dayKey}") { + SectionHeader(section.dayLabel) + } + items(section.rows, key = { it.key }) { row -> + Row( modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - items(uiState.folders, key = { it.resourceId }) { folder -> - FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) }) + FileCard(file = row.left, modifier = Modifier.weight(1f)) + if (row.right != null) { + FileCard(file = row.right, modifier = Modifier.weight(1f)) + } else { + Spacer(Modifier.weight(1f)) } } } @@ -208,32 +206,61 @@ private fun FolderListContent( } @Composable -private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) { +private fun SectionHeader(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + ) +} + +/** Carte fichier — miroir du `FileCard` de l'écran d'accueil JS (non cliquable). */ +@Composable +private fun FileCard(file: FileEntity, modifier: Modifier = Modifier) { Card( - onClick = onClick, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, Color(0xFFEAEAEA)), + shape = RoundedCornerShape(10.dp), + modifier = modifier, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + Column(modifier = Modifier.padding(12.dp)) { Icon( - imageVector = Icons.Filled.Folder, + imageVector = Icons.Filled.InsertDriveFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(28.dp), + modifier = Modifier.size(36.dp), ) + Spacer(Modifier.height(8.dp)) Text( - text = folder.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + text = file.name, + style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = formatSize(file.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } +} + +/** Miroir de `formatSize` (app/index.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/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt index e97472f..83b6df5 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -1,9 +1,24 @@ package com.vaultdrop.mobile.ui.folderlist -import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.local.entity.FileEntity + +/** Section d'un jour dans la grille d'accueil — miroir de `FileSection` (app/index.tsx). */ +data class FileSection( + /** Début du jour en millis (fuseau local) — clé stable de la section. */ + val dayKey: Long, + val dayLabel: String, + val rows: List, +) + +/** Rangée de grille : 2 cartes maximum, la droite est facultative. */ +data class FilePair( + val key: String, + val left: FileEntity, + val right: FileEntity? = null, +) data class FolderListUiState( - val folders: List = emptyList(), + val sections: List = emptyList(), val isRefreshing: Boolean = false, /** true pendant l'exploration SAF d'une racine (marche récursive en cours). */ val isScanning: Boolean = false, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index 55b49ca..6a96476 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -3,7 +3,9 @@ package com.vaultdrop.mobile.ui.folderlist 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.remote.ApiException +import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.SaveFolderInput import com.vaultdrop.mobile.features.sync.DeviceSync @@ -14,11 +16,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale import javax.inject.Inject @HiltViewModel class FolderListViewModel @Inject constructor( private val folderRepository: FolderRepository, + private val fileRepository: FileRepository, private val tokenProvider: TokenProvider, private val deviceSync: DeviceSync, ) : ViewModel() { @@ -27,18 +35,50 @@ class FolderListViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() init { - observeFolders() + observeFiles() refresh() } - private fun observeFolders() { + /** Grille d'accueil : tous les fichiers visibles, groupés par jour (addedAt). */ + private fun observeFiles() { viewModelScope.launch { - folderRepository.observeRootFolders().collect { folders -> - _uiState.update { it.copy(folders = folders) } + fileRepository.observeAllVisible().collect { files -> + _uiState.update { it.copy(sections = groupFilesByDay(files)) } } } } + /** Miroir de `groupFilesByDay` (app/index.tsx) : sections triées du plus récent + * au plus vieux, fichiers d'un jour triés par date décroissante, en paires. */ + private fun groupFilesByDay(files: List): List { + val zone = ZoneId.systemDefault() + return files + .groupBy { startOfDay(it.addedAt, zone) } + .entries + .sortedByDescending { it.key } + .map { (day, list) -> + val rows = list.sortedByDescending { it.addedAt } + .chunked(2) + .map { pair -> + FilePair( + key = pair.joinToString { it.resourceId }, + left = pair[0], + right = pair.getOrNull(1), + ) + } + FileSection(dayKey = day, dayLabel = formatDayLabel(day, zone), rows = rows) + } + } + + private fun startOfDay(millis: Long, zone: ZoneId): Long = + Instant.ofEpochMilli(millis).atZone(zone).toLocalDate() + .atStartOfDay(zone).toInstant().toEpochMilli() + + private fun formatDayLabel(millis: Long, zone: ZoneId): String = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .format(Instant.ofEpochMilli(millis).atZone(zone).toLocalDate()) + fun refresh() { viewModelScope.launch { // Sans token → mode local : on ne tient pas de GET /files/folders. 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 4d2abf9..1d50831 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 @@ -54,7 +54,6 @@ fun NavGraph() { FolderListScreen( selectedTab = selectedTab, onTabSelected = onTabSelected, - onOpenFolder = { id -> navController.navigate(Routes.folder(id)) }, ) } composable(Routes.SEARCH) {