This commit is contained in:
m
2026-09-11 16:56:49 +02:00
parent 28586931d3
commit bca29ed26f
6 changed files with 177 additions and 90 deletions
@@ -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<List<FileEntity>>
@Query("SELECT * FROM files WHERE \"exists\" = 1 ORDER BY added_at DESC, name ASC")
fun observeAllVisible(): Flow<List<FileEntity>>
@Query("SELECT * FROM files WHERE resource_id = :resourceId LIMIT 1")
suspend fun getByResourceId(resourceId: String): FileEntity?
@@ -28,6 +28,9 @@ class FileRepository @Inject constructor(
fun observeFiles(folderResourceId: String): Flow<List<FileEntity>> =
fileDao.observeByFolder(folderResourceId)
/** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */
fun observeAllVisible(): Flow<List<FileEntity>> = fileDao.observeAllVisible()
suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId)
@@ -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()) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item(key = "actions") {
Button(
onClick = onAddFolder,
enabled = !uiState.isScanning,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_folder))
}
}
if (uiState.isScanning) {
item(key = "scanning") {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.padding(top = 12.dp),
)
}
}
uiState.error?.let { error ->
item(key = "error") {
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.padding(top = 12.dp),
)
}
when {
uiState.folders.isEmpty() && uiState.isRefreshing -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
uiState.folders.isEmpty() -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
if (uiState.sections.isEmpty() && !uiState.isScanning) {
item(key = "empty") {
Text(
text = stringResource(R.string.no_folders_yet),
text = stringResource(R.string.no_files_yet),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.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),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.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,
) {
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))
}
@@ -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<FilePair>,
)
/** 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<FolderEntity> = emptyList(),
val sections: List<FileSection> = emptyList(),
val isRefreshing: Boolean = false,
/** true pendant l'exploration SAF d'une racine (marche récursive en cours). */
val isScanning: Boolean = false,
@@ -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<FolderListUiState> = _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<FileEntity>): List<FileSection> {
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.
@@ -54,7 +54,6 @@ fun NavGraph() {
FolderListScreen(
selectedTab = selectedTab,
onTabSelected = onTabSelected,
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
)
}
composable(Routes.SEARCH) {