diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt index 6028099..2d5805a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -62,7 +62,9 @@ import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer +import com.vaultdrop.mobile.ui.document.content.ImageViewer import com.vaultdrop.mobile.ui.document.content.PdfFocusViewer +import com.vaultdrop.mobile.ui.document.content.TextFocusViewer import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId @@ -75,8 +77,9 @@ import java.util.Locale * externe, carte de métadonnées, navigation au swipe entre les documents (même * fil que l'écran d'accueil). * - * Les PDF locaux passent en mode plein écran sur tap : barres système et barre - * d'outils masquées, document verrouillé, indication de page en bas. + * Les documents locaux lisibles (PDF, image, texte) passent en mode focus sur + * tap : barres système et barre d'outils masquées, fond noir, un tap ou le + * retour système quitte le mode. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -114,7 +117,7 @@ fun DocumentViewerScreen( } var fullscreen by rememberSaveable { mutableStateOf(false) } - val canFullscreen = currentFile?.canPdfFullscreen() == true + val canFullscreen = currentFile?.canFocus() == true // Mode immersif : masque les barres système en plein écran, les restaure // à la sortie ou si l'écran est composé autrement. @@ -176,7 +179,7 @@ fun DocumentViewerScreen( ) { padding -> val current = currentFile if (fullscreen && canFullscreen && current != null) { - PdfFullscreenReader( + FocusDocumentReader( file = current, onOpenExternalFailed = onOpenExternalFailed, onExitFullscreen = { fullscreen = false }, @@ -194,7 +197,7 @@ fun DocumentViewerScreen( DocumentViewerPage( file = file, onOpenExternalFailed = onOpenExternalFailed, - onEnterFullscreen = file.canPdfFullscreen() + onEnterFullscreen = file.canFocus() .takeIf { it } ?.let { { fullscreen = true } }, ) @@ -205,11 +208,84 @@ fun DocumentViewerScreen( } /** - * Lecture PDF focus : le document remplit l'écran (fond noir), une page par - * écran défiler verticalement, un tap quitte le plein écran et une pastille - * indique la page courante. + * Lecteur focus du document courant : dispatch par catégorie. + * + * - PDF → une page par écran, défilement vertical, pastille de page + * - IMAGE → document plein écran sur fond noir + * - TEXT → lecture immersive (fond sombre, texte clair, sélectionnable) + * + * Le retour (tap ou bouton système) est géré par le cadre commun [FocusScaffold]. */ @Composable +private fun FocusDocumentReader( + file: FileEntity, + onOpenExternalFailed: () -> Unit, + onExitFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + when (file.categoryValue()) { + FileCategory.PDF -> PdfFullscreenReader( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + onExitFullscreen = onExitFullscreen, + modifier = modifier, + ) + FileCategory.IMAGE -> ImageFullscreenReader( + file = file, + onExitFullscreen = onExitFullscreen, + modifier = modifier, + ) + FileCategory.TEXT -> TextFullscreenReader( + file = file, + onExitFullscreen = onExitFullscreen, + modifier = modifier, + ) + else -> Unit + } +} + +/** + * Cadre commun du mode focus : fond noir plein écran, un tap (l'indicatrice de + * position est optionnelle) quitte le mode. + */ +@Composable +private fun FocusScaffold( + file: FileEntity, + onExitFullscreen: () -> Unit, + modifier: Modifier = Modifier, + position: String? = null, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + .pointerInput(file.resourceId) { + detectTapGestures(onTap = { onExitFullscreen() }) + }, + ) { + content() + if (position != null) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + shape = RoundedCornerShape(percent = 50), + color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), + ) { + Text( + text = position, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.inverseOnSurface, + ) + } + } + } +} + +/** Lecture PDF focus : une page par écran, swipe vertical, pastille de page. */ +@Composable private fun PdfFullscreenReader( file: FileEntity, onOpenExternalFailed: () -> Unit, @@ -219,13 +295,11 @@ private fun PdfFullscreenReader( var pageCount by remember(file.resourceId) { mutableIntStateOf(0) } val pagerState = rememberPagerState(pageCount = { pageCount }) - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black) - .pointerInput(file.resourceId) { - detectTapGestures(onTap = { onExitFullscreen() }) - }, + FocusScaffold( + file = file, + onExitFullscreen = onExitFullscreen, + modifier = modifier, + position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null, ) { PdfFocusViewer( file = file, @@ -235,22 +309,59 @@ private fun PdfFullscreenReader( modifier = Modifier.fillMaxSize(), onPageCountChanged = { pageCount = it }, ) - if (pageCount > 0) { - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 16.dp), - shape = RoundedCornerShape(percent = 50), - color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), - ) { - Text( - text = "${pagerState.currentPage + 1} / $pageCount", - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.inverseOnSurface, - ) - } - } + } +} + +/** Image focus : l'image remplit l'écran (fit) sur fond noir. */ +@Composable +private fun ImageFullscreenReader( + file: FileEntity, + onExitFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + val contentResolver = LocalContext.current.contentResolver + val uri = file.uri + if (uri == null) { + return + } + + FocusScaffold( + file = file, + onExitFullscreen = onExitFullscreen, + modifier = modifier, + ) { + ImageViewer( + contentResolver = contentResolver, + uri = uri, + contentDescription = file.name, + modifier = Modifier.fillMaxSize(), + ) + } +} + +/** Texte focus : lecture immersive fond sombre, texte clair, sélectionnable. */ +@Composable +private fun TextFullscreenReader( + file: FileEntity, + onExitFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + val contentResolver = LocalContext.current.contentResolver + val uri = file.uri + if (uri == null) { + return + } + + FocusScaffold( + file = file, + onExitFullscreen = onExitFullscreen, + modifier = modifier, + ) { + TextFocusViewer( + contentResolver = contentResolver, + uri = uri, + modifier = Modifier.fillMaxSize(), + ) } } @@ -331,9 +442,11 @@ private fun fileTypeLabel(file: FileEntity): String = ?: file.mimeType?.uppercase(Locale.getDefault()) ?: "—" -/** Vrai si le fichier est un PDF avec un contenu local consultable en plein écran. */ -private fun FileEntity.canPdfFullscreen(): Boolean = - uri != null && categoryValue() == FileCategory.PDF +/** Vrai si le fichier est local et appartient à une catégorie consultable en mode focus. */ +private fun FileEntity.canFocus(): Boolean = + uri != null && categoryValue() in FOCUS_CATEGORIES + +private val FOCUS_CATEGORIES = setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT) private fun formatDateTime(millis: Long): String { val locale = Locale.getDefault() diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt index dedae72..ecadb4a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.vaultdrop.mobile.R @@ -90,6 +91,73 @@ fun TextDocumentViewer( } } +/** + * Lecture texte en mode focus : fond sombre, texte clair, défilement vertical. + * Reprend la lecture bornée (bandeau de troncature) et la sélection de texte + * du mode classique, avec les couleurs inversées sur fond noir. + */ +@Composable +fun TextFocusViewer( + contentResolver: ContentResolver, + uri: String, + modifier: Modifier = Modifier, +) { + var text by remember(uri) { mutableStateOf(null) } + var truncated by remember(uri) { mutableStateOf(false) } + var failed by remember(uri) { mutableStateOf(false) } + + LaunchedEffect(uri) { + text = null + truncated = false + failed = false + val result = withContext(Dispatchers.IO) { readCapped(contentResolver, uri) } + result?.let { + text = it.first + truncated = it.second + } ?: run { failed = true } + } + + Box(modifier.fillMaxSize()) { + when { + failed -> Text( + text = stringResource(R.string.document_cannot_read), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + text == null -> Text( + text = stringResource(R.string.document_loading), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + else -> Column(Modifier.fillMaxSize()) { + if (truncated) { + Text( + text = stringResource(R.string.document_truncated_notice), + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFFFD54F), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + SelectionContainer(Modifier.weight(1f)) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + item { + Text( + text = text.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + } + } + } + } + } + } +} + /** Lit le flux UTF-8 borné à MAX_BYTES (+1 pour détecter la troncature). */ private fun readCapped(contentResolver: ContentResolver, uri: String): Pair? { val stream = DocumentContent.openInputStream(contentResolver, uri) ?: return null