Merge pull request #3982 from vitorpamplona/claude/picture-dialog-button-clickability-7q2whv

fix(viewer): full-screen viewer chrome — clickable buttons, PDF parity, and chrome that follows the system bars
This commit is contained in:
Vitor Pamplona
2026-08-25 15:00:59 -04:00
committed by GitHub
4 changed files with 459 additions and 227 deletions
@@ -0,0 +1,343 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.os.Build
import android.view.Window
import android.widget.Toast
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
// Chrome shared by the full-screen media viewers -- the zoomable image/video dialog and the PDF
// viewer. Both are opened the same way (tap a media card in a feed), so they immerse, auto-hide,
// and lay their controls out identically.
// Opening churn -- insets arriving, then the bars being hidden -- must not look like a user
// gesture, so the row snaps through it and only animates afterwards.
private const val CONTROLS_SETTLE_BEFORE_ANIMATING_MS = 350L
// Roughly the system bars' own show/hide duration, so the row travels with them rather than
// trailing after they have already arrived.
private const val CONTROLS_SLIDE_MS = 200
// Keeps the row off the screen edge -- and off the rounded corners -- while the bars are hidden.
private val VIEWER_CHROME_EDGE_GAP = 16.dp
// How long the controls stay up before the viewer fades them out on its own.
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 2000L
/**
* Goes fully immersive for as long as the viewer is on screen: hides both OS bars and restores them
* on the way out. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user swipe to peek the bars back.
*/
@Composable
fun ImmersiveSystemBarsEffect(window: Window?) {
val view = LocalView.current
DisposableEffect(window, view) {
val controller = window?.let { WindowInsetsControllerCompat(it, view) }
controller?.apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
hide(WindowInsetsCompat.Type.systemBars())
}
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
}
}
/**
* Visibility of the viewer controls: they start on screen, fade out on their own after
* [CONTROLS_AUTO_HIDE_DELAY_MS], and the caller flips the returned state on tap.
*
* [holdOpen] freezes the timer while something anchored to the controls -- the share sheet, say --
* is up, and re-arms it once that closes. [armed] withholds the countdown until there is something
* to look at, so a viewer that spends three seconds fetching its media doesn't reveal the first
* frame with the controls already gone.
*
* A tap that brings the controls back deliberately gets no timer: the user asked for them, so they
* stay until tapped away. That is why the countdown races the controls going away rather than just
* sleeping -- a timer left over from an earlier show would otherwise wipe controls the user tapped
* back up in the meantime.
*/
@Composable
fun rememberViewerControlsVisibility(
holdOpen: Boolean,
armed: Boolean = true,
): MutableState<Boolean> {
val visible = remember { mutableStateOf(true) }
LaunchedEffect(armed, holdOpen) {
if (!armed || holdOpen) return@LaunchedEffect
val hiddenFirst =
withTimeoutOrNull(CONTROLS_AUTO_HIDE_DELAY_MS) {
snapshotFlow { visible.value }.first { !it }
}
if (hiddenFirst == null) visible.value = false
}
return visible
}
/**
* How far the viewer chrome sits from a screen edge: the system bar's own height while the bar is
* on screen, and a thin constant once it is hidden -- so the chrome follows the bar instead of
* reserving space for one that is not there.
*
* This only works because the viewer asks for BEHAVIOR_DEFAULT rather than transient bars. Under
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE a peeked bar is painted over the content and dispatches no
* insets at all -- `systemBars` stays 0 and `isVisible` stays false the whole time it is on screen
* -- so nothing here could react to it.
*
* The value is animated, but snapped for [CONTROLS_SETTLE_BEFORE_ANIMATING_MS] after the chrome
* appears. Opening moves the inset twice for reasons the user did not cause: the window has not
* been told its insets yet (they read 0), and ImmersiveSystemBarsEffect hides the bars from a
* DisposableEffect that runs after composition. Animating either would play a slide on open.
*/
@Composable
fun animatedViewerChromeInset(atBottom: Boolean): Dp {
val density = LocalDensity.current
val bars = WindowInsets.systemBars
val barPx = if (atBottom) bars.getBottom(density) else bars.getTop(density)
val target = with(density) { maxOf(barPx, VIEWER_CHROME_EDGE_GAP.roundToPx()).toDp() }
var settled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(CONTROLS_SETTLE_BEFORE_ANIMATING_MS)
settled = true
}
val animated by animateDpAsState(
targetValue = target,
animationSpec = if (settled) tween(durationMillis = CONTROLS_SLIDE_MS) else snap(),
label = "viewerChromeInset",
)
return animated
}
/**
* Lays a viewer control row along a screen edge -- the top by default, the bottom when [atBottom].
*
* The viewer hides the system bars, so the row would otherwise sit against the screen edge. It
* takes its distance from [animatedViewerChromeInset], which follows the bar on and off screen
* rather than permanently reserving room for it.
*
* Horizontal display-cutout insets are still applied outright: a landscape notch eats into the
* sides whatever the bars are doing. The top cutout is deliberately not applied, because on a
* punch-hole device it is a centred hole that the edge-anchored buttons are nowhere near -- and
* honouring it as a full-width top inset would push them down by the height of a camera they do
* not overlap.
*
* The row also holds a button's height whatever it carries, so content that outlives the buttons
* doesn't shift as they come and go.
*/
@Composable
@OptIn(ExperimentalLayoutApi::class)
fun ViewerControlsRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = spacedBy(Size10dp),
atBottom: Boolean = false,
content: @Composable RowScope.() -> Unit,
) {
// systemBars is visibility-aware under BEHAVIOR_DEFAULT: 0 while the bars are hidden, and the
// real bar size once the user swipes them in -- so the controls follow them instead of sitting
// under a bar or reserving space for one that is not there. The 16dp floor keeps them clear of
// the rounded corners while hidden. Animated so the row slides rather than jumps.
val animatedInset = animatedViewerChromeInset(atBottom)
Row(
modifier =
modifier
.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal))
.padding(
top = if (atBottom) 0.dp else animatedInset,
bottom = if (atBottom) animatedInset else 0.dp,
).padding(horizontal = Size15dp, vertical = Size10dp)
.fillMaxWidth()
.heightIn(min = ButtonDefaults.MinHeight),
horizontalArrangement = horizontalArrangement,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
/** Leaves the viewer. Always the first item in the control row. */
@Composable
fun ViewerBackButton(onDismiss: () -> Unit) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
}
/** Opens the share sheet for whatever the viewer is showing. The sheet anchors to the button. */
@Composable
fun ViewerShareButton(
content: BaseMediaContent,
popupExpanded: MutableState<Boolean>,
accountViewModel: AccountViewModel,
) {
OutlinedButton(
onClick = { popupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
content = content,
onDismiss = { popupExpanded.value = false },
)
}
}
/**
* Saves the media to the gallery. Q and up write through MediaStore and need no permission; older
* releases ask for WRITE_EXTERNAL_STORAGE first and save as soon as it is granted.
*/
@Composable
@OptIn(ExperimentalPermissionsApi::class)
fun ViewerSaveToGalleryButton(
content: BaseMediaContent,
accountViewModel: AccountViewModel,
) {
// The application context and the view model's scope, never the composition's: this button
// lives inside the AnimatedVisibility that the auto-hide collapses two seconds after the tap
// that started the download, and a rememberCoroutineScope job would be cancelled with it --
// killing the save with no file and no error. Matches the download row in ShareMediaAction.
val localContext = LocalContext.current.applicationContext
val scope = accountViewModel.viewModelScope
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
scope.launch {
saveMediaToGallery(content, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
}
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
scope.launch(Dispatchers.IO) {
saveMediaToGallery(content, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.download_to_phone),
)
}
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
@@ -61,14 +60,15 @@ fun SlidingCarousel(
) {
HorizontalPager(state = pagerState) { page -> itemContent(page) }
// you can remove the surface in case you don't want
// the transparent bacground
// The dots ride the same animated inset as the rest of the viewer chrome, so they slide
// with the navigation bar instead of snapping the moment it appears. navigationBarsPadding()
// tracks the bar correctly but moves in one frame, which reads as a jump next to the
// controls sliding at the top.
Surface(
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 8.dp),
.padding(bottom = animatedViewerChromeInset(atBottom = true) + 8.dp),
shape = CircleShape,
color = Color.Black.copy(alpha = 0.5f),
) {
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.WindowManager
@@ -35,31 +33,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -71,22 +60,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
@@ -101,19 +81,11 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@@ -211,18 +183,9 @@ fun ZoomableImageDialog(
dialogWindow.attributes = attributes
}
// Go fully immersive while the full-screen media viewer is open: hide both OS bars and
// restore them when the dialog closes. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user
// swipe to peek the bars. Applies to full-screen images and video alike (shared dialog).
val dialogView = LocalView.current
DisposableEffect(dialogWindow, dialogView) {
val controller = dialogWindow?.let { WindowInsetsControllerCompat(it, dialogView) }
controller?.apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
}
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen
// images and video alike (shared dialog), and matches the PDF viewer.
ImmersiveSystemBarsEffect(dialogWindow)
Box(modifier = Modifier.fillMaxSize()) {
// Background surface that fades in as the content grows to fullscreen.
@@ -250,7 +213,6 @@ fun ZoomableImageDialog(
}
@Composable
@OptIn(ExperimentalPermissionsApi::class)
private fun DialogContent(
allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent,
@@ -264,29 +226,13 @@ private fun DialogContent(
accountViewModel: AccountViewModel,
) {
val pagerState: PagerState = rememberPagerState { allImages.size }
val controllerVisible = remember { mutableStateOf(true) }
val sharePopupExpanded = remember { mutableStateOf(false) }
val controllerVisible = rememberViewerControlsVisibility(holdOpen = sharePopupExpanded.value)
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
launch {
val page = allImages.indexOf(imageUrl)
if (page > -1) {
pagerState.scrollToPage(page)
}
}
launch {
delay(2000)
if (!sharePopupExpanded.value) {
controllerVisible.value = false
}
}
}
// Re-trigger auto-hide after the share dialog is dismissed
LaunchedEffect(sharePopupExpanded.value) {
if (!sharePopupExpanded.value && controllerVisible.value) {
delay(2000)
controllerVisible.value = false
val page = allImages.indexOf(imageUrl)
if (page > -1) {
pagerState.scrollToPage(page)
}
}
@@ -390,100 +336,20 @@ private fun DialogContent(
// Also fade with the grow animation so controls appear/disappear alongside it.
modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) },
) {
Row(
modifier =
Modifier
.padding(horizontal = Size15dp, vertical = Size10dp)
.statusBarsPadding()
.systemBarsPadding()
.fillMaxWidth(),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
ViewerControlsRow {
ViewerBackButton(onDismiss)
Spacer(modifier = Modifier.weight(1f))
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
if (myContent is MediaUrlImage || myContent is MediaLocalImage) {
OutlinedButton(
onClick = { sharePopupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = sharePopupExpanded, myContent, onDismiss = { sharePopupExpanded.value = false })
}
ViewerShareButton(myContent, sharePopupExpanded, accountViewModel)
}
val isPdfOrStaticImage = myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf
val isNotLiveStream = myContent !is MediaUrlContent || !isHlsMedia(myContent.url, myContent.mimeType)
if (isPdfOrStaticImage && isNotLiveStream) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
scope.launch {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
}
}
OutlinedButton(
onClick = {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
scope.launch(Dispatchers.IO) {
saveMediaToGallery(myContent, localContext, accountViewModel)
}
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.media_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.download_to_phone),
)
}
ViewerSaveToGalleryButton(myContent, accountViewModel)
}
}
}
@@ -23,24 +23,23 @@ package com.vitorpamplona.amethyst.ui.components.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -62,21 +61,22 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.createBitmap
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.ImmersiveSystemBarsEffect
import com.vitorpamplona.amethyst.ui.components.ViewerBackButton
import com.vitorpamplona.amethyst.ui.components.ViewerControlsRow
import com.vitorpamplona.amethyst.ui.components.ViewerSaveToGalleryButton
import com.vitorpamplona.amethyst.ui.components.ViewerShareButton
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.ui.components.rememberViewerControlsVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -106,6 +106,10 @@ private const val HI_RES_DEBOUNCE_MS = 200L
// threshold region where we swap in the hi-res bitmap.
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
// How long the page counter stays up on its own after a page turn, once the reader has hidden the
// chrome. Long enough to read "7 / 24" without putting the controls back on top of the page.
private const val PAGE_INDICATOR_FLASH_MS = 1500L
// How many recently-rendered pages to keep around. Pager already pre-composes the
// current page plus one neighbor; this just speeds up small back/forward swipes.
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
@@ -165,6 +169,9 @@ fun PdfViewerDialog(
decorFitsSystemWindows = false,
),
) {
// Go fully immersive while the viewer is open, exactly like the image/video dialog.
ImmersiveSystemBarsEffect(getDialogWindow())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
PdfViewerContent(
content = content,
@@ -219,31 +226,44 @@ private fun PdfViewerContent(
}
val sharePopupExpanded = remember { mutableStateOf(false) }
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
val handle = handleState
if (handle == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
} else if (handle.pageCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Unable to open PDF",
color = Color.White,
)
}
} else {
val pagerState = rememberPagerState { handle.pageCount }
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
Box(modifier = Modifier.fillMaxSize()) {
// A PDF that takes longer than the auto-hide delay to fetch would otherwise reveal its first
// page with the chrome already gone, and nothing left to re-arm the timer.
val controlsVisible =
rememberViewerControlsVisibility(
holdOpen = sharePopupExpanded.value,
armed = handle != null,
)
val pagerState = rememberPagerState { handle?.pageCount ?: 0 }
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
// The page counter is wayfinding rather than a control, so it outlives the buttons for a
// moment after every page turn -- a reader who tapped the chrome away still sees where a
// swipe landed.
var pageJustChanged by remember { mutableStateOf(false) }
LaunchedEffect(pagerState.currentPage) {
pageJustChanged = true
delay(PAGE_INDICATOR_FLASH_MS)
pageJustChanged = false
}
val toggleControls = { if (!sharePopupExpanded.value) controlsVisible.value = !controlsVisible.value }
Box(modifier = Modifier.fillMaxSize().clickable(onClick = toggleControls)) {
if (handle == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
} else if (handle.pageCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Unable to open PDF",
color = Color.White,
)
}
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
@@ -252,53 +272,54 @@ private fun PdfViewerContent(
handle = handle,
pageIndex = pageIndex,
cache = pageCache,
// The zoomable page consumes the tap before the box underneath ever sees it,
// so the toggle has to hang off the gesture detector that owns it.
onTap = toggleControls,
)
}
}
Row(
modifier =
Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.statusBarsPadding()
.systemBarsPadding()
.padding(horizontal = Size15dp, vertical = Size10dp),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
// The buttons hold the top edge; the page counter lives along the bottom. Keeping them on
// one strip meant the counter sat dead centre under the display cutout on punch-hole
// devices, which is exactly where a front camera is. Down here it is clear of the cutout,
// stays centred on the screen rather than on whatever space the asymmetric button groups
// leave behind, and reads as wayfinding rather than as another control.
ViewerControlsRow(modifier = Modifier.align(Alignment.TopCenter)) {
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
ViewerBackButton(onDismiss)
}
Spacer(modifier = Modifier.weight(1f))
if (handle != null) {
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
Row(horizontalArrangement = spacedBy(Size10dp)) {
ViewerShareButton(content, sharePopupExpanded, accountViewModel)
ViewerSaveToGalleryButton(content, accountViewModel)
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
color = Color.White,
modifier =
Modifier
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
.padding(horizontal = Size10dp, vertical = Size5dp),
)
Spacer(modifier = Modifier.weight(1f))
OutlinedButton(
onClick = { sharePopupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
if (handle != null && handle.pageCount > 0) {
ViewerControlsRow(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.Center,
atBottom = true,
) {
AnimatedVisibility(
visible = controlsVisible.value || pageJustChanged,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
symbol = MaterialSymbols.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
Text(
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
color = Color.White,
modifier =
Modifier
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
.padding(horizontal = Size10dp, vertical = Size5dp),
)
}
}
@@ -312,6 +333,7 @@ private fun PdfPageView(
handle: PdfDocumentHandle,
pageIndex: Int,
cache: PageBitmapCache,
onTap: () -> Unit,
) {
val cached = cache.get(pageIndex)
@@ -373,6 +395,7 @@ private fun PdfPageView(
.fillMaxSize()
.zoomable(
zoomState = zoomState,
onTap = { onTap() },
onDoubleTap = { position ->
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
},