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") @Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>> 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") @Query("SELECT * FROM files WHERE resource_id = :resourceId LIMIT 1")
suspend fun getByResourceId(resourceId: String): FileEntity? suspend fun getByResourceId(resourceId: String): FileEntity?
@@ -28,6 +28,9 @@ class FileRepository @Inject constructor(
fun observeFiles(folderResourceId: String): Flow<List<FileEntity>> = fun observeFiles(folderResourceId: String): Flow<List<FileEntity>> =
fileDao.observeByFolder(folderResourceId) 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? = suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId) fileDao.getByResourceId(resourceId)
@@ -7,27 +7,28 @@ import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -38,8 +39,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R 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.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.NavTab
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FolderListScreen( fun FolderListScreen(
selectedTab: NavTab, selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit, onTabSelected: (NavTab) -> Unit,
onOpenFolder: (String) -> Unit,
viewModel: FolderListViewModel = hiltViewModel(), viewModel: FolderListViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -99,7 +100,6 @@ fun FolderListScreen(
) { padding -> ) { padding ->
FolderListContent( FolderListContent(
uiState = uiState, uiState = uiState,
onOpenFolder = onOpenFolder,
onAddFolder = { pickFolderLauncher.launch(null) }, onAddFolder = { pickFolderLauncher.launch(null) },
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
@@ -124,82 +124,80 @@ private fun Uri.displayName(context: Context): String? = runCatching {
@Composable @Composable
private fun FolderListContent( private fun FolderListContent(
uiState: FolderListUiState, uiState: FolderListUiState,
onOpenFolder: (String) -> Unit,
onAddFolder: () -> Unit, onAddFolder: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxSize()) { LazyColumn(
Button( modifier = modifier.fillMaxSize(),
onClick = onAddFolder, contentPadding = PaddingValues(horizontal = 16.dp),
enabled = !uiState.isScanning, ) {
modifier = Modifier item(key = "actions") {
.fillMaxWidth() Button(
.padding(horizontal = 16.dp, vertical = 12.dp), onClick = onAddFolder,
) { enabled = !uiState.isScanning,
Icon(Icons.Filled.Add, contentDescription = null) modifier = Modifier
Spacer(Modifier.width(8.dp)) .fillMaxWidth()
Text(stringResource(R.string.add_folder)) .padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_folder))
}
} }
if (uiState.isScanning) { if (uiState.isScanning) {
LinearProgressIndicator( item(key = "scanning") {
modifier = Modifier LinearProgressIndicator(
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 16.dp), .fillMaxWidth()
) .padding(top = 12.dp),
)
}
} }
uiState.error?.let { error -> uiState.error?.let { error ->
Text( item(key = "error") {
text = error, Text(
style = MaterialTheme.typography.bodyMedium, text = error,
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.error,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(top = 12.dp),
) )
}
} }
when { if (uiState.sections.isEmpty() && !uiState.isScanning) {
uiState.folders.isEmpty() && uiState.isRefreshing -> { item(key = "empty") {
Box( Text(
text = stringResource(R.string.no_files_yet),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
uiState.folders.isEmpty() -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .padding(vertical = 32.dp),
contentAlignment = Alignment.Center, )
) {
Text(
text = stringResource(R.string.no_folders_yet),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
}
else -> { uiState.sections.forEach { section ->
LazyColumn( item(key = "header-${section.dayKey}") {
SectionHeader(section.dayLabel)
}
items(section.rows, key = { it.key }) { row ->
Row(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.fillMaxWidth(), .padding(bottom = 12.dp),
contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(uiState.folders, key = { it.resourceId }) { folder -> FileCard(file = row.left, modifier = Modifier.weight(1f))
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) }) 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 @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( Card(
onClick = onClick, colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), border = BorderStroke(1.dp, Color(0xFFEAEAEA)),
shape = RoundedCornerShape(10.dp),
modifier = modifier,
) { ) {
Row( Column(modifier = Modifier.padding(12.dp)) {
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon( Icon(
imageVector = Icons.Filled.Folder, imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp), modifier = Modifier.size(36.dp),
) )
Spacer(Modifier.height(8.dp))
Text( Text(
text = folder.name, text = file.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Medium, 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 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( data class FolderListUiState(
val folders: List<FolderEntity> = emptyList(), val sections: List<FileSection> = emptyList(),
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
/** true pendant l'exploration SAF d'une racine (marche récursive en cours). */ /** true pendant l'exploration SAF d'une racine (marche récursive en cours). */
val isScanning: Boolean = false, val isScanning: Boolean = false,
@@ -3,7 +3,9 @@ package com.vaultdrop.mobile.ui.folderlist
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.auth.TokenProvider 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.remote.ApiException
import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.features.sync.DeviceSync import com.vaultdrop.mobile.features.sync.DeviceSync
@@ -14,11 +16,17 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class FolderListViewModel @Inject constructor( class FolderListViewModel @Inject constructor(
private val folderRepository: FolderRepository, private val folderRepository: FolderRepository,
private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
private val deviceSync: DeviceSync, private val deviceSync: DeviceSync,
) : ViewModel() { ) : ViewModel() {
@@ -27,18 +35,50 @@ class FolderListViewModel @Inject constructor(
val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow() val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow()
init { init {
observeFolders() observeFiles()
refresh() refresh()
} }
private fun observeFolders() { /** Grille d'accueil : tous les fichiers visibles, groupés par jour (addedAt). */
private fun observeFiles() {
viewModelScope.launch { viewModelScope.launch {
folderRepository.observeRootFolders().collect { folders -> fileRepository.observeAllVisible().collect { files ->
_uiState.update { it.copy(folders = folders) } _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() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
// Sans token → mode local : on ne tient pas de GET /files/folders. // Sans token → mode local : on ne tient pas de GET /files/folders.
@@ -54,7 +54,6 @@ fun NavGraph() {
FolderListScreen( FolderListScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
) )
} }
composable(Routes.SEARCH) { composable(Routes.SEARCH) {