add focus mode on text and document

This commit is contained in:
m
2026-09-12 10:30:49 +02:00
parent 7dd3bc66f5
commit e52624aed7
2 changed files with 215 additions and 34 deletions
@@ -62,7 +62,9 @@ import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.ui.components.ServerStatusBadge import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.components.categoryValue
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer 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.PdfFocusViewer
import com.vaultdrop.mobile.ui.document.content.TextFocusViewer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.ZoneId 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 * externe, carte de métadonnées, navigation au swipe entre les documents (même
* fil que l'écran d'accueil). * fil que l'écran d'accueil).
* *
* Les PDF locaux passent en mode plein écran sur tap : barres système et barre * Les documents locaux lisibles (PDF, image, texte) passent en mode focus sur
* d'outils masquées, document verrouillé, indication de page en bas. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -114,7 +117,7 @@ fun DocumentViewerScreen(
} }
var fullscreen by rememberSaveable { mutableStateOf(false) } 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 // Mode immersif : masque les barres système en plein écran, les restaure
// à la sortie ou si l'écran est composé autrement. // à la sortie ou si l'écran est composé autrement.
@@ -176,7 +179,7 @@ fun DocumentViewerScreen(
) { padding -> ) { padding ->
val current = currentFile val current = currentFile
if (fullscreen && canFullscreen && current != null) { if (fullscreen && canFullscreen && current != null) {
PdfFullscreenReader( FocusDocumentReader(
file = current, file = current,
onOpenExternalFailed = onOpenExternalFailed, onOpenExternalFailed = onOpenExternalFailed,
onExitFullscreen = { fullscreen = false }, onExitFullscreen = { fullscreen = false },
@@ -194,7 +197,7 @@ fun DocumentViewerScreen(
DocumentViewerPage( DocumentViewerPage(
file = file, file = file,
onOpenExternalFailed = onOpenExternalFailed, onOpenExternalFailed = onOpenExternalFailed,
onEnterFullscreen = file.canPdfFullscreen() onEnterFullscreen = file.canFocus()
.takeIf { it } .takeIf { it }
?.let { { fullscreen = true } }, ?.let { { fullscreen = true } },
) )
@@ -205,11 +208,84 @@ fun DocumentViewerScreen(
} }
/** /**
* Lecture PDF focus : le document remplit l'écran (fond noir), une page par * Lecteur focus du document courant : dispatch par catégorie.
* écran défiler verticalement, un tap quitte le plein écran et une pastille *
* indique la page courante. * - 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 @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( private fun PdfFullscreenReader(
file: FileEntity, file: FileEntity,
onOpenExternalFailed: () -> Unit, onOpenExternalFailed: () -> Unit,
@@ -219,13 +295,11 @@ private fun PdfFullscreenReader(
var pageCount by remember(file.resourceId) { mutableIntStateOf(0) } var pageCount by remember(file.resourceId) { mutableIntStateOf(0) }
val pagerState = rememberPagerState(pageCount = { pageCount }) val pagerState = rememberPagerState(pageCount = { pageCount })
Box( FocusScaffold(
modifier = modifier file = file,
.fillMaxSize() onExitFullscreen = onExitFullscreen,
.background(Color.Black) modifier = modifier,
.pointerInput(file.resourceId) { position = if (pageCount > 0) "${pagerState.currentPage + 1} / $pageCount" else null,
detectTapGestures(onTap = { onExitFullscreen() })
},
) { ) {
PdfFocusViewer( PdfFocusViewer(
file = file, file = file,
@@ -235,22 +309,59 @@ private fun PdfFullscreenReader(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onPageCountChanged = { pageCount = it }, onPageCountChanged = { pageCount = it },
) )
if (pageCount > 0) { }
Surface( }
modifier = Modifier
.align(Alignment.BottomCenter) /** Image focus : l'image remplit l'écran (fit) sur fond noir. */
.padding(bottom = 16.dp), @Composable
shape = RoundedCornerShape(percent = 50), private fun ImageFullscreenReader(
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), file: FileEntity,
onExitFullscreen: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Text( val contentResolver = LocalContext.current.contentResolver
text = "${pagerState.currentPage + 1} / $pageCount", val uri = file.uri
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), if (uri == null) {
style = MaterialTheme.typography.labelMedium, return
color = MaterialTheme.colorScheme.inverseOnSurface, }
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()) ?: file.mimeType?.uppercase(Locale.getDefault())
?: "" ?: ""
/** Vrai si le fichier est un PDF avec un contenu local consultable en plein écran. */ /** Vrai si le fichier est local et appartient à une catégorie consultable en mode focus. */
private fun FileEntity.canPdfFullscreen(): Boolean = private fun FileEntity.canFocus(): Boolean =
uri != null && categoryValue() == FileCategory.PDF uri != null && categoryValue() in FOCUS_CATEGORIES
private val FOCUS_CATEGORIES = setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT)
private fun formatDateTime(millis: Long): String { private fun formatDateTime(millis: Long): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
@@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vaultdrop.mobile.R 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<String?>(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). */ /** Lit le flux UTF-8 borné à MAX_BYTES (+1 pour détecter la troncature). */
private fun readCapped(contentResolver: ContentResolver, uri: String): Pair<String, Boolean>? { private fun readCapped(contentResolver: ContentResolver, uri: String): Pair<String, Boolean>? {
val stream = DocumentContent.openInputStream(contentResolver, uri) ?: return null val stream = DocumentContent.openInputStream(contentResolver, uri) ?: return null