Skip to content

Commit 7caab6b

Browse files
authored
Merge pull request #69 from yamada-sexta/feat-fullscreen-attachments-8854220472692893442
feat: unified full screen attachment viewer
2 parents bfad8be + adcea30 commit 7caab6b

File tree

8 files changed

+360
-13
lines changed

8 files changed

+360
-13
lines changed

app/src/main/java/org/example/memosm/ui/component/item/AttachmentCard.kt

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ fun AttachmentCard(
9393
showSize: Boolean = true,
9494
showFilename: Boolean = true,
9595
compactMode: AttachmentCompactMode = AttachmentCompactMode.Area,
96+
isFullScreen: Boolean = false,
97+
onClick: (() -> Unit)? = null,
98+
onDismiss: (() -> Unit)? = null,
9699
onRatioAvailable: (Float, Boolean) -> Unit = { _, _ -> }
97100
) {
98101
val context = LocalContext.current
@@ -303,7 +306,10 @@ fun AttachmentCard(
303306
intrinsicRatio = it
304307
isIntrinsicExact = true
305308
},
306-
onClick = { showFullScreenImage = true })
309+
onClick = if (isFullScreen) null else { onClick ?: { showFullScreenImage = true } },
310+
isFullScreen = isFullScreen,
311+
onDismiss = onDismiss
312+
)
307313
} else if (isVideo) {
308314
val videoUrl =
309315
if (uri != Uri.EMPTY) uri.toString() else AttachmentManager.getAttachmentUrl(
@@ -314,6 +320,9 @@ fun AttachmentCard(
314320
url = videoUrl,
315321
token = token,
316322
modifier = Modifier.fillMaxSize(),
323+
isFullScreen = isFullScreen,
324+
onClick = if (isFullScreen) null else onClick,
325+
onDismiss = onDismiss,
317326
onRatioAvailable = {
318327
intrinsicRatio = it
319328
isIntrinsicExact = true
@@ -325,6 +334,7 @@ fun AttachmentCard(
325334
filename = filename,
326335
token = token,
327336
mode = when {
337+
isFullScreen -> AudioPlayerMode.NORMAL
328338
isWide -> AudioPlayerMode.WIDE
329339
isCompact -> AudioPlayerMode.COMPACT
330340
else -> AudioPlayerMode.NORMAL
@@ -350,17 +360,21 @@ fun AttachmentCard(
350360
filename = filename,
351361
isRound = true,
352362
modifier = Modifier.fillMaxSize(),
353-
onClick = { showFullScreenImage = true })
363+
onClick = if (isFullScreen) null else { onClick ?: { showFullScreenImage = true } },
364+
isFullScreen = isFullScreen,
365+
onDismiss = onDismiss
366+
)
354367
} else {
355368
FileThumbnail(
356369
displayType = displayType,
357370
filename = filename,
358371
mode = when {
372+
isFullScreen -> FileThumbnailMode.NORMAL
359373
isWide -> FileThumbnailMode.WIDE
360374
isCompact -> FileThumbnailMode.COMPACT
361375
else -> FileThumbnailMode.NORMAL
362376
},
363-
onClick = { showInfoDialog = true },
377+
onClick = if (isFullScreen) { {} } else { onClick ?: { showInfoDialog = true } },
364378
modifier = Modifier.fillMaxSize()
365379
)
366380
}
@@ -596,7 +610,7 @@ fun AttachmentCard(
596610
}
597611
}
598612

599-
if (model != null) {
613+
if (model != null && !isFullScreen) {
600614
FullScreenImageViewer(
601615
model = model,
602616
filename = filename,

app/src/main/java/org/example/memosm/ui/component/item/MemoItem.kt

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import org.example.memosm.model.Reaction
9191
import org.example.memosm.model.User
9292
import org.example.memosm.ui.VisibilityIcon
9393
import org.example.memosm.ui.component.item.markdown.NativeComposeMarkdown
94+
import org.example.memosm.ui.component.item.media.FullScreenAttachmentViewer
9495
import org.example.memosm.ui.component.resolveResourceUrl
9596

9697

@@ -122,6 +123,8 @@ fun MemoItem(
122123
var showMenu by remember { mutableStateOf(false) }
123124
var showReactionPicker by remember { mutableStateOf(false) }
124125
var showRawTextDialog by remember { mutableStateOf(false) }
126+
var showFullScreenViewer by remember { mutableStateOf(false) }
127+
var fullScreenInitialIndex by remember { mutableStateOf(0) }
125128
val context = LocalContext.current
126129

127130
val unknownTime = stringResource(R.string.memo_unknown_time)
@@ -533,7 +536,7 @@ fun MemoItem(
533536
.fillMaxWidth()
534537
.padding(end = 8.dp)
535538
) {
536-
attachments.forEach { attachment ->
539+
attachments.forEachIndexed { index, attachment ->
537540
var aspectRatio by remember(
538541
attachment.name ?: attachment.filename
539542
) {
@@ -547,6 +550,10 @@ fun MemoItem(
547550
.fillMaxWidth()
548551
.aspectRatio(aspectRatio),
549552
compactMode = AttachmentCompactMode.Never,
553+
onClick = {
554+
fullScreenInitialIndex = index
555+
showFullScreenViewer = true
556+
},
550557
onRatioAvailable = { ratio, _ -> aspectRatio = ratio })
551558
}
552559
}
@@ -591,7 +598,11 @@ fun MemoItem(
591598
hostUrl = hostUrl,
592599
modifier = Modifier.size(width = 240.dp, height = 160.dp),
593600
showInfo = false,
594-
compactMode = AttachmentCompactMode.Area
601+
compactMode = AttachmentCompactMode.Area,
602+
onClick = {
603+
fullScreenInitialIndex = index
604+
showFullScreenViewer = true
605+
}
595606
)
596607
}
597608
}
@@ -686,6 +697,17 @@ fun MemoItem(
686697
}
687698
})
688699
}
700+
701+
if (showFullScreenViewer) {
702+
val attachments = remember(memo.attachments) { memo.attachments ?: emptyList() }
703+
FullScreenAttachmentViewer(
704+
attachments = attachments,
705+
initialIndex = fullScreenInitialIndex,
706+
token = token,
707+
hostUrl = hostUrl,
708+
onDismiss = { showFullScreenViewer = false }
709+
)
710+
}
689711
}
690712

691713

app/src/main/java/org/example/memosm/ui/component/item/markdown/NativeMarkdownAttachmentImage.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ fun NativeMarkdownAttachmentImage(content: String, node: ASTNode) {
6363
showSize = false,
6464
showFilename = false,
6565
compactMode = AttachmentCompactMode.Never,
66+
onClick = null, // Defaults to showFullScreenImage inside AttachmentCard which is acceptable for isolated markdown images.
6667
onRatioAvailable = { ratio, _ ->
6768
if (ratio > 0) {
6869
aspectRatio = ratio
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package org.example.memosm.ui.component.item.media
2+
3+
import androidx.compose.animation.core.Animatable
4+
import androidx.compose.foundation.background
5+
import androidx.compose.foundation.gestures.detectVerticalDragGestures
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.fillMaxSize
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.layout.statusBarsPadding
10+
import androidx.compose.foundation.pager.HorizontalPager
11+
import androidx.compose.foundation.pager.rememberPagerState
12+
import androidx.compose.foundation.shape.CircleShape
13+
import androidx.compose.material.icons.Icons
14+
import androidx.compose.material.icons.outlined.Close
15+
import androidx.compose.material3.Icon
16+
import androidx.compose.material3.IconButton
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.runtime.LaunchedEffect
19+
import androidx.compose.runtime.SideEffect
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.runtime.rememberCoroutineScope
22+
import androidx.compose.ui.Alignment
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.graphics.Color
25+
import androidx.compose.ui.graphics.graphicsLayer
26+
import androidx.compose.ui.input.pointer.pointerInput
27+
import androidx.compose.ui.platform.LocalView
28+
import androidx.compose.ui.res.stringResource
29+
import androidx.activity.compose.PredictiveBackHandler
30+
import kotlinx.coroutines.flow.catch
31+
import androidx.compose.ui.unit.dp
32+
import androidx.compose.ui.window.Dialog
33+
import androidx.compose.ui.window.DialogProperties
34+
import androidx.compose.ui.window.DialogWindowProvider
35+
import androidx.core.view.WindowCompat
36+
import androidx.core.view.WindowInsetsCompat
37+
import androidx.core.view.WindowInsetsControllerCompat
38+
import kotlinx.coroutines.launch
39+
import org.example.memosm.R
40+
import org.example.memosm.model.Attachment
41+
import org.example.memosm.ui.component.item.AttachmentCard
42+
import org.example.memosm.ui.component.item.AttachmentCompactMode
43+
import kotlin.math.abs
44+
45+
@Composable
46+
fun FullScreenAttachmentViewer(
47+
attachments: List<Attachment>,
48+
initialIndex: Int,
49+
token: String?,
50+
hostUrl: String,
51+
onDismiss: () -> Unit,
52+
onPageChanged: ((Int) -> Unit)? = null
53+
) {
54+
if (attachments.isEmpty() || initialIndex < 0 || initialIndex >= attachments.size) {
55+
return
56+
}
57+
58+
Dialog(
59+
onDismissRequest = onDismiss, properties = DialogProperties(
60+
usePlatformDefaultWidth = false, decorFitsSystemWindows = false
61+
)
62+
) {
63+
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
64+
if (window != null) {
65+
SideEffect {
66+
val controller = WindowCompat.getInsetsController(window, window.decorView)
67+
controller.hide(WindowInsetsCompat.Type.systemBars())
68+
controller.systemBarsBehavior =
69+
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
70+
}
71+
}
72+
73+
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { attachments.size })
74+
val coroutineScope = rememberCoroutineScope()
75+
76+
// Vertical drag to dismiss
77+
val dismissOffset = remember { Animatable(0f) }
78+
79+
// Predictive back gesture state
80+
val backProgress = remember { Animatable(0f) }
81+
val backEdgeProgress = remember { Animatable(0f) }
82+
83+
val dismissDragProgress = (abs(dismissOffset.value) / 300f).coerceIn(0f, 1f)
84+
85+
// Combine background alpha and scale from both gestures
86+
val combinedAlpha = ((1f - dismissDragProgress) * (1f - backProgress.value * 0.5f)).coerceIn(0f, 1f)
87+
val combinedScale = ((1f - (abs(dismissOffset.value) / 1000f)) * (1f - backProgress.value * 0.1f)).coerceIn(0.6f, 1f)
88+
89+
LaunchedEffect(pagerState.currentPage) {
90+
onPageChanged?.invoke(pagerState.currentPage)
91+
}
92+
93+
PredictiveBackHandler { progress ->
94+
try {
95+
progress.collect { backEvent ->
96+
backProgress.snapTo(backEvent.progress)
97+
// Edge 0 is left, 1 is right
98+
backEdgeProgress.snapTo(if (backEvent.swipeEdge == 0) 1f else -1f)
99+
}
100+
onDismiss()
101+
} catch (e: Exception) {
102+
// Cancelled
103+
backProgress.animateTo(0f)
104+
backEdgeProgress.animateTo(0f)
105+
}
106+
}
107+
108+
Box(
109+
modifier = Modifier
110+
.fillMaxSize()
111+
.background(Color.Black.copy(alpha = combinedAlpha))
112+
) {
113+
Box(
114+
modifier = Modifier
115+
.fillMaxSize()
116+
.pointerInput(Unit) {
117+
detectVerticalDragGestures(
118+
onDragEnd = {
119+
coroutineScope.launch {
120+
if (abs(dismissOffset.value) > 200f) {
121+
onDismiss()
122+
} else {
123+
dismissOffset.animateTo(0f)
124+
}
125+
}
126+
},
127+
onVerticalDrag = { change, dragAmount ->
128+
change.consume()
129+
coroutineScope.launch {
130+
dismissOffset.snapTo(dismissOffset.value + dragAmount)
131+
}
132+
}
133+
)
134+
}
135+
.graphicsLayer {
136+
scaleX = combinedScale
137+
scaleY = combinedScale
138+
translationY = dismissOffset.value
139+
// Shift horizontally slightly based on predictive back edge
140+
translationX = backProgress.value * 100f * backEdgeProgress.value
141+
}
142+
) {
143+
HorizontalPager(
144+
state = pagerState,
145+
modifier = Modifier.fillMaxSize()
146+
) { page ->
147+
val attachment = attachments[page]
148+
149+
// The AttachmentCard should handle its own zooming internally if isFullScreen=true.
150+
// The swipe up/down gesture is captured by the parent box above,
151+
// unless the child consumes it (which it shouldn't if we just want swipe to dismiss).
152+
AttachmentCard(
153+
attachment = attachment,
154+
token = token,
155+
hostUrl = hostUrl,
156+
modifier = Modifier.fillMaxSize(),
157+
showInfo = false,
158+
showActions = false,
159+
showSize = false,
160+
showFilename = false,
161+
compactMode = AttachmentCompactMode.Never,
162+
isFullScreen = true,
163+
onDismiss = onDismiss
164+
)
165+
}
166+
}
167+
168+
// Close Button
169+
Box(
170+
modifier = Modifier
171+
.align(Alignment.TopEnd)
172+
.padding(16.dp)
173+
.statusBarsPadding()
174+
.graphicsLayer { alpha = combinedAlpha }) {
175+
IconButton(
176+
onClick = onDismiss,
177+
modifier = Modifier.background(Color.Black.copy(alpha = 0.4f), CircleShape)
178+
) {
179+
Icon(
180+
imageVector = Icons.Outlined.Close,
181+
contentDescription = stringResource(R.string.common_close),
182+
tint = Color.White
183+
)
184+
}
185+
}
186+
}
187+
}
188+
}

app/src/main/java/org/example/memosm/ui/component/item/media/MemoImage.kt

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import androidx.compose.ui.layout.ContentScale
2828
import androidx.compose.ui.platform.LocalContext
2929
import androidx.compose.ui.res.stringResource
3030
import androidx.compose.ui.unit.dp
31+
import androidx.compose.foundation.gestures.detectTransformGestures
32+
import androidx.compose.ui.input.pointer.pointerInput
33+
import androidx.compose.ui.graphics.graphicsLayer
3134
import coil3.compose.AsyncImage
3235
import coil3.network.NetworkHeaders
3336
import coil3.network.httpHeaders
@@ -50,7 +53,9 @@ fun MemoImage(
5053
isRound: Boolean = false,
5154
placeholderIcon: ImageVector? = null,
5255
onRatioAvailable: (Float) -> Unit = {},
53-
onClick: (() -> Unit)? = null
56+
onClick: (() -> Unit)? = null,
57+
isFullScreen: Boolean = false,
58+
onDismiss: (() -> Unit)? = null
5459
) {
5560
val context = LocalContext.current
5661
val modelState = produceState<Any?>(initialValue = null, uri, attachment, hostUrl) {
@@ -114,14 +119,16 @@ fun MemoImage(
114119
modifier = modifier, contentAlignment = Alignment.Center
115120
) {
116121
if (model != null) {
122+
val imgModifier = Modifier
123+
.fillMaxSize()
124+
.then(if (isRound) Modifier.clip(CircleShape) else Modifier)
125+
.then(if (onClick != null && !isFullScreen) Modifier.clickable { onClick() } else Modifier)
126+
117127
AsyncImage(
118128
model = imageRequest,
119129
contentDescription = filename,
120-
modifier = Modifier
121-
.fillMaxSize()
122-
.then(if (isRound) Modifier.clip(CircleShape) else Modifier)
123-
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
124-
contentScale = ContentScale.Crop,
130+
modifier = imgModifier.zoomable(isFullScreen, onDismiss),
131+
contentScale = if (isFullScreen) ContentScale.Fit else ContentScale.Crop,
125132
onLoading = { isLoading = true; isError = false },
126133
onSuccess = { state ->
127134
isLoading = false

0 commit comments

Comments
 (0)