diff --git a/app/src/main/java/dev/dimension/flare/ui/common/OnNewIntent.kt b/app/src/main/java/dev/dimension/flare/ui/common/OnNewIntent.kt index ac3716c9d..09c1f5db9 100644 --- a/app/src/main/java/dev/dimension/flare/ui/common/OnNewIntent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/common/OnNewIntent.kt @@ -4,18 +4,10 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.Intent -import android.util.DisplayMetrics -import android.view.WindowManager import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.core.util.Consumer private fun Context.getActivity(): Activity { @@ -41,35 +33,3 @@ fun OnNewIntent( onDispose { activity.removeOnNewIntentListener(listener) } } } - -@Composable -fun FullScreenBox( - modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit, -) { - val context = LocalContext.current - val windowManager = - remember { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } - - val (widthPx, heightPx) = - remember(windowManager) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds.width() to - windowManager.currentWindowMetrics.bounds.height() - } else { - DisplayMetrics().apply { - @Suppress("DEPRECATION") - windowManager.defaultDisplay.getRealMetrics(this) - }.let { - it.widthPixels to it.heightPixels - } - } - } - val (width, height) = - with(LocalDensity.current) { - remember(widthPx, heightPx) { - Pair(widthPx.toDp(), heightPx.toDp()) - } - } - Box(modifier = Modifier.requiredSize(width, height).then(modifier), content = content) -} diff --git a/app/src/main/java/dev/dimension/flare/ui/component/NetworkImage.kt b/app/src/main/java/dev/dimension/flare/ui/component/NetworkImage.kt index 6d49b6c27..e00ab39bc 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/NetworkImage.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/NetworkImage.kt @@ -44,7 +44,17 @@ fun NetworkImage( filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, ) { SubcomposeAsyncImage( - model = model, + model = + ImageRequest.Builder(LocalContext.current) + .data(model) + .let { + if (model is String) { + it.memoryCacheKey(model) + } else { + it + } + } + .build(), contentDescription = contentDescription, alignment = alignment, contentScale = contentScale, @@ -60,7 +70,7 @@ fun NetworkImage( @Composable fun EmojiImage( - uri: Any?, + uri: String, modifier: Modifier = Modifier, ) { val context = LocalContext.current diff --git a/app/src/main/java/dev/dimension/flare/ui/component/VideoPlayer.kt b/app/src/main/java/dev/dimension/flare/ui/component/VideoPlayer.kt index 49e69e7a8..8352ec757 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/VideoPlayer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/VideoPlayer.kt @@ -15,11 +15,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM import androidx.media3.ui.PlayerView @OptIn(UnstableApi::class) @@ -33,6 +33,8 @@ fun VideoPlayer( showControls: Boolean = false, keepScreenOn: Boolean = false, aspectRatio: Float? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { var isLoaded by remember { mutableStateOf(false) } Box(modifier = modifier) { @@ -57,11 +59,9 @@ fun VideoPlayer( } }, ) - if (aspectRatio == null) { - this.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - } } PlayerView(context).apply { + controllerShowTimeoutMs = -1 useController = showControls player = exoPlayer layoutParams = @@ -69,7 +69,21 @@ fun VideoPlayer( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) + if (aspectRatio == null) { + this.resizeMode = RESIZE_MODE_ZOOM + } this.keepScreenOn = keepScreenOn + if (onClick != null) { + setOnClickListener { + onClick() + } + } + if (onLongClick != null) { + setOnLongClickListener { + onLongClick() + true + } + } } }, onRelease = { @@ -97,7 +111,8 @@ fun VideoPlayer( } else { it } - }, + } + .fillMaxSize(), ) } LinearProgressIndicator( diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index 4c1d8e0ad..7c02ea336 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -71,7 +71,7 @@ fun CommonStatusComponent( user: UiUser, medias: ImmutableList, humanizedTime: String, - onMediaClick: (statusKey: MicroBlogKey, index: Int) -> Unit, + onMediaClick: (statusKey: MicroBlogKey, index: Int, preview: String?) -> Unit, onUserClick: (MicroBlogKey) -> Unit, modifier: Modifier = Modifier, sensitive: Boolean = false, @@ -192,6 +192,12 @@ fun CommonStatusComponent( onMediaClick.invoke( statusKey, medias.indexOf(it), + when (it) { + is UiMedia.Image -> it.previewUrl + is UiMedia.Video -> it.thumbnailUrl + is UiMedia.Gif -> it.previewUrl + else -> null + }, ) }, sensitive = sensitive, @@ -241,6 +247,12 @@ fun CommonStatusComponent( onMediaClick.invoke( quotedStatus.statusKey, quotedStatus.medias.indexOf(it), + when (it) { + is UiMedia.Image -> it.previewUrl + is UiMedia.Video -> it.thumbnailUrl + is UiMedia.Gif -> it.previewUrl + else -> null + }, ) }, onClick = { diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt index e2d50fadb..5d00eebfa 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/LazyStatusItems.kt @@ -363,35 +363,10 @@ internal class DefaultStatusEvent( override fun onMediaClick( statusKey: MicroBlogKey, index: Int, + preview: String?, uriHandler: UriHandler, ) { - uriHandler.openUri(StatusMediaRouteDestination(statusKey, index).deeplink()) -// when (media) { -// is UiMedia.Image -> { -// uriHandler.openUri(MediaRouteDestination(media.url).deeplink()) -// } -// -// is UiMedia.Audio -> Unit -// is UiMedia.Gif -> { -// uriHandler.openUri( -// VideoRouteDestination( -// media.url, -// previewUri = media.previewUrl, -// contentDescription = media.description, -// ).deeplink(), -// ) -// } -// -// is UiMedia.Video -> { -// uriHandler.openUri( -// VideoRouteDestination( -// media.url, -// previewUri = media.thumbnailUrl, -// contentDescription = media.description, -// ).deeplink(), -// ) -// } -// } + uriHandler.openUri(StatusMediaRouteDestination(statusKey, index, preview).deeplink()) } override fun onDeleteClick( @@ -702,6 +677,7 @@ internal data object EmptyStatusEvent : StatusEvent { override fun onMediaClick( statusKey: MicroBlogKey, index: Int, + preview: String?, uriHandler: UriHandler, ) = Unit diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/QuotedStatus.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/QuotedStatus.kt index 1ff439b0a..70f41a4ab 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/QuotedStatus.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/QuotedStatus.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -29,6 +31,7 @@ internal fun UiStatusQuoted( status: UiStatus, onMediaClick: (UiMedia) -> Unit, modifier: Modifier = Modifier, + colors: CardColors = CardDefaults.cardColors(), onClick: () -> Unit = {}, showMedia: Boolean = true, ) { @@ -47,6 +50,7 @@ internal fun UiStatusQuoted( onClick = onClick, sensitive = status.sensitive, showMedia = showMedia, + colors = colors, ) } is UiStatus.MastodonNotification -> Unit @@ -64,6 +68,7 @@ internal fun UiStatusQuoted( onClick = onClick, sensitive = status.sensitive, showMedia = showMedia, + colors = colors, ) is UiStatus.MisskeyNotification -> Unit @@ -81,6 +86,7 @@ internal fun UiStatusQuoted( onClick = onClick, sensitive = false, showMedia = showMedia, + colors = colors, ) is UiStatus.BlueskyNotification -> Unit is UiStatus.XQT -> @@ -97,6 +103,7 @@ internal fun UiStatusQuoted( onClick = onClick, sensitive = status.sensitive, showMedia = showMedia, + colors = colors, ) } } @@ -114,10 +121,12 @@ private fun QuotedStatus( onMediaClick: (UiMedia) -> Unit, showMedia: Boolean, modifier: Modifier = Modifier, + colors: CardColors = CardDefaults.cardColors(), onClick: () -> Unit = {}, ) { Card( modifier = modifier, + colors = colors, onClick = onClick, ) { Column { diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt index a970fa2ff..c9ceb6530 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/StatusMediaComponent.kt @@ -174,7 +174,7 @@ fun MediaItem( when (media) { is UiMedia.Image -> { NetworkImage( - model = media.url, + model = media.previewUrl, contentDescription = media.description, modifier = Modifier diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt index 40bfd52dc..9e10cf5fe 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/bluesky/BlueskyStatusComponent.kt @@ -48,10 +48,11 @@ internal fun BlueskyStatusComponent( } .then(modifier), statusKey = data.statusKey, - onMediaClick = { statusKey, index -> + onMediaClick = { statusKey, index, preview -> event.onMediaClick( statusKey = statusKey, index = index, + preview = preview, uriHandler = uriHandler, ) }, @@ -256,6 +257,7 @@ internal interface BlueskyStatusEvent { fun onMediaClick( statusKey: MicroBlogKey, index: Int, + preview: String?, uriHandler: UriHandler, ) diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/mastodon/MastodonStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/mastodon/MastodonStatusComponent.kt index c08e0360c..cf199c5e4 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/mastodon/MastodonStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/mastodon/MastodonStatusComponent.kt @@ -121,10 +121,11 @@ internal fun MastodonStatusComponent( } .then(modifier), statusKey = actualData.statusKey, - onMediaClick = { statusKey, index -> + onMediaClick = { statusKey, index, preview -> event.onMediaClick( statusKey = statusKey, index = index, + preview = preview, uriHandler = uriHandler, ) }, @@ -368,6 +369,7 @@ internal interface MastodonStatusEvent { fun onMediaClick( statusKey: MicroBlogKey, index: Int, + preview: String?, uriHandler: UriHandler, ) diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/misskey/MisskeyStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/misskey/MisskeyStatusComponent.kt index 475b08903..4bb796cd6 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/misskey/MisskeyStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/misskey/MisskeyStatusComponent.kt @@ -67,10 +67,11 @@ internal fun MisskeyStatusComponent( } .then(modifier), statusKey = actualData.statusKey, - onMediaClick = { statusKey, index -> + onMediaClick = { statusKey, index, preview -> event.onMediaClick( statusKey = statusKey, index = index, + preview = preview, uriHandler = uriHandler, ) }, @@ -364,6 +365,7 @@ internal interface MisskeyStatusEvent { fun onMediaClick( statusKey: MicroBlogKey, index: Int, + preview: String?, uriHandler: UriHandler, ) diff --git a/app/src/main/java/dev/dimension/flare/ui/component/status/xqt/XQTStatusComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/status/xqt/XQTStatusComponent.kt index 6f34ea6ae..2c6c06b60 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/status/xqt/XQTStatusComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/status/xqt/XQTStatusComponent.kt @@ -49,10 +49,11 @@ internal fun XQTStatusComponent( } .then(modifier), statusKey = actualData.statusKey, - onMediaClick = { statusKey, index -> + onMediaClick = { statusKey, index, preview -> event.onMediaClick( statusKey = statusKey, index = index, + preview = preview, uriHandler = uriHandler, ) }, @@ -271,6 +272,7 @@ internal interface XQTStatusEvent { fun onMediaClick( statusKey: MicroBlogKey, index: Int, + preview: String?, uriHandler: UriHandler, ) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt index 63346b749..554a8b27a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/MediaScreen.kt @@ -15,8 +15,13 @@ import android.view.WindowManager import android.webkit.MimeTypeMap import android.widget.FrameLayout import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Save @@ -27,22 +32,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider -import coil3.annotation.ExperimentalCoilApi +import coil3.compose.rememberAsyncImagePainter import coil3.imageLoader +import coil3.request.ImageRequest import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -53,13 +62,15 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import dev.dimension.flare.R import dev.dimension.flare.molecule.producePresenter -import dev.dimension.flare.ui.common.FullScreenBox import dev.dimension.flare.ui.theme.FlareTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.ZoomableContentLocation +import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.zoomable import moe.tlaster.swiper.Swiper import moe.tlaster.swiper.rememberSwiperState import org.koin.compose.koinInject @@ -118,6 +129,7 @@ fun MediaRoute( uri: String, navigator: DestinationsNavigator, ) { + SetDialogDestinationToEdgeToEdge() MediaScreen( uri = uri, onDismiss = navigator::navigateUp, @@ -127,6 +139,7 @@ fun MediaRoute( @OptIn( ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class, + ExperimentalFoundationApi::class, ) @Composable internal fun MediaScreen( @@ -150,7 +163,7 @@ internal fun MediaScreen( rememberSwiperState( onDismiss = onDismiss, ) - FullScreenBox( + Box( modifier = Modifier .fillMaxSize() @@ -158,16 +171,41 @@ internal fun MediaScreen( .alpha(1 - swiperState.progress), ) { Swiper(state = swiperState) { - ZoomableAsyncImage( - model = uri, + val zoomableState = + rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 10f)) + val painter = + rememberAsyncImagePainter( + model = + ImageRequest.Builder(LocalContext.current) + .data(uri) + .build(), + ) + LaunchedEffect(painter.intrinsicSize) { + zoomableState.setContentLocation( + ZoomableContentLocation.scaledInsideAndCenterAligned( + painter.intrinsicSize, + ), + ) + } + Image( + painter = painter, contentDescription = null, - onLongClick = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - state.setShowMenu(true) - }, + contentScale = ContentScale.Inside, + alignment = Alignment.Center, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .zoomable(zoomableState) + .combinedClickable( + onClick = { + }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + state.setShowMenu(true) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), ) } if (state.showMenu) { @@ -208,7 +246,6 @@ internal fun MediaScreen( } } -@OptIn(ExperimentalCoilApi::class) @Composable private fun mediaPresenter( uri: String, @@ -272,7 +309,7 @@ private fun getMimeType(byteArray: ByteArray): String { return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg" // 默认为JPEG } -private fun saveByteArrayToDownloads( +internal fun saveByteArrayToDownloads( context: Context, byteArray: ByteArray, fileName: String, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt index a8c6a132c..49c3256b9 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/StatusMediaScreen.kt @@ -1,26 +1,72 @@ package dev.dimension.flare.ui.screen.media +import android.Manifest +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import coil3.imageLoader +import coil3.request.ImageRequest +import com.eygraber.compose.placeholder.material3.placeholder +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.FULL_ROUTE_PLACEHOLDER import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.dimension.flare.R import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.molecule.producePresenter import dev.dimension.flare.ui.component.VideoPlayer @@ -28,15 +74,24 @@ import dev.dimension.flare.ui.component.status.UiStatusQuoted import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.medias +import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.status.StatusPresenter import dev.dimension.flare.ui.presenter.status.StatusState import dev.dimension.flare.ui.screen.destinations.StatusRouteDestination import dev.dimension.flare.ui.theme.FlareTheme -import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.ZoomableContentLocation +import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.zoomable import moe.tlaster.swiper.Swiper import moe.tlaster.swiper.rememberSwiperState +import org.koin.compose.koinInject @Composable @Destination( @@ -50,6 +105,7 @@ import moe.tlaster.swiper.rememberSwiperState fun StatusMediaRoute( statusKey: MicroBlogKey, index: Int, + preview: String?, navigator: DestinationsNavigator, ) { SetDialogDestinationToEdgeToEdge() @@ -57,22 +113,38 @@ fun StatusMediaRoute( statusKey = statusKey, index = index, onDismiss = navigator::navigateUp, + preview = preview, toStatus = { navigator.navigate(StatusRouteDestination(statusKey)) }, ) } -@OptIn(ExperimentalFoundationApi::class) +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, + ExperimentalPermissionsApi::class, +) @Composable internal fun StatusMediaScreen( statusKey: MicroBlogKey, index: Int, + preview: String?, toStatus: () -> Unit, onDismiss: () -> Unit, ) { + val context = LocalContext.current + val haptics = LocalHapticFeedback.current + val permissionState = + rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) val state by producePresenter { - statusMediaPresenter(statusKey) + statusMediaPresenter(statusKey, index, context) + } + + BackHandler(state.showUi) { + state.setShowUi(false) } FlareTheme(darkTheme = true) { @@ -83,64 +155,192 @@ internal fun StatusMediaScreen( Box( modifier = Modifier -// .fillMaxSize() + .fillMaxSize() .background(MaterialTheme.colorScheme.background.copy(alpha = 1 - swiperState.progress)) .alpha(1 - swiperState.progress), ) { Swiper(state = swiperState) { - state.medias.onSuccess { medias -> - HorizontalPager( - state = + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + state.medias.onSuccess { medias -> + val pagerState = rememberPagerState( initialPage = index, pageCount = { medias.size }, - ), - ) { - when (val media = medias[it]) { - is UiMedia.Audio -> - VideoPlayer( - uri = media.url, - previewUri = null, - contentDescription = media.description, - modifier = Modifier.fillMaxSize(), - ) + ) + LaunchedEffect(pagerState.currentPage) { + state.setWithVideoPadding(medias[pagerState.currentPage] is UiMedia.Video) + state.setCurrentPage(pagerState.currentPage) + } + HorizontalPager( + state = pagerState, + ) { + when (val media = medias[it]) { + is UiMedia.Audio -> + VideoPlayer( + uri = media.url, + previewUri = null, + contentDescription = media.description, + modifier = + Modifier + .fillMaxSize(), + onClick = { + state.setShowUi(!state.showUi) + }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + state.setShowMenu(true) + }, + ) - is UiMedia.Gif -> - VideoPlayer( - uri = media.url, - previewUri = media.previewUrl, - contentDescription = media.description, - modifier = Modifier.fillMaxSize(), - aspectRatio = media.aspectRatio, - ) + is UiMedia.Gif -> + VideoPlayer( + uri = media.url, + previewUri = media.previewUrl, + contentDescription = media.description, + modifier = + Modifier + .fillMaxSize(), + onClick = { + state.setShowUi(!state.showUi) + }, + aspectRatio = media.aspectRatio, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + state.setShowMenu(true) + }, + ) - is UiMedia.Image -> - ZoomableAsyncImage( - model = media.url, - contentDescription = media.description, - modifier = Modifier.fillMaxSize(), - ) + is UiMedia.Image -> { + val zoomableState = + rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 10f)) + val painter = + rememberAsyncImagePainter( + model = + ImageRequest.Builder(LocalContext.current) + .data(media.url) + .placeholderMemoryCacheKey(media.previewUrl) + .build(), + ) + LaunchedEffect(painter.intrinsicSize) { + zoomableState.setContentLocation( + ZoomableContentLocation.scaledInsideAndCenterAligned( + painter.intrinsicSize, + ), + ) + } + Image( + painter = painter, + contentDescription = media.description, + contentScale = ContentScale.Inside, + alignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .zoomable(zoomableState) + .combinedClickable( + onClick = { + state.setShowUi(!state.showUi) + }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + state.setShowMenu(true) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + ) + } - is UiMedia.Video -> - VideoPlayer( - uri = media.url, - previewUri = media.thumbnailUrl, - contentDescription = media.description, - modifier = Modifier.fillMaxSize(), - aspectRatio = media.aspectRatio, - showControls = true, - keepScreenOn = true, - muted = false, - ) + is UiMedia.Video -> + VideoPlayer( + uri = media.url, + previewUri = media.thumbnailUrl, + contentDescription = media.description, + modifier = + Modifier + .fillMaxSize(), + onClick = { + state.setShowUi(!state.showUi) + }, + aspectRatio = media.aspectRatio, + showControls = true, + keepScreenOn = true, + muted = false, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + state.setShowMenu(true) + }, + ) + } + } + if (pagerState.pageCount > 1) { + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp) + .systemBarsPadding(), + horizontalArrangement = Arrangement.Center, + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f) + } + Box( + modifier = + Modifier + .padding(2.dp) + .clip(CircleShape) + .background(color) + .size(8.dp), + ) + } + } + } + }.onLoading { + if (preview != null) { + AsyncImage( + model = + ImageRequest.Builder(LocalContext.current) + .data(preview) + .memoryCacheKey(preview) + .build(), + contentDescription = null, + modifier = + Modifier + .fillMaxSize(), + ) + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } else { + Box( + modifier = + Modifier + .aspectRatio(1f) + .fillMaxSize() + .placeholder(true), + ) } } } } state.status.onSuccess { status -> AnimatedVisibility( - visible = swiperState.progress == 0f, + visible = swiperState.progress == 0f && state.showUi, modifier = Modifier .align(Alignment.BottomCenter), @@ -154,26 +354,136 @@ internal fun StatusMediaScreen( showMedia = false, modifier = Modifier + .padding( + bottom = if (state.withVideoPadding) 72.dp else 0.dp, + ) .systemBarsPadding(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) } } + state.medias.onSuccess { medias -> + if (state.showMenu) { + ModalBottomSheet( + onDismissRequest = { + state.setShowMenu(false) + }, + ) { + ListItem( + headlineContent = { + Text(text = stringResource(R.string.media_menu_save)) + }, + leadingContent = { + Icon( + Icons.Default.Save, + contentDescription = stringResource(R.string.media_menu_save), + ) + }, + modifier = + Modifier + .clickable { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (!permissionState.status.isGranted) { + permissionState.launchPermissionRequest() + } else { + state.setShowMenu(false) + val url = + when (val media = medias[state.currentPage]) { + is UiMedia.Audio -> media.url + is UiMedia.Gif -> media.url + is UiMedia.Image -> media.url + is UiMedia.Video -> media.url + } + state.save(url) + } + } else { + state.setShowMenu(false) + val url = + when (val media = medias[state.currentPage]) { + is UiMedia.Audio -> media.url + is UiMedia.Gif -> media.url + is UiMedia.Image -> media.url + is UiMedia.Video -> media.url + } + state.save(url) + } + }, + ) + } + } + } } } } @Composable -private fun statusMediaPresenter(statusKey: MicroBlogKey) = - run { - val state = - remember(statusKey) { - StatusPresenter(statusKey) - }.invoke() - val medias = - state.status.map { - it.medias +private fun statusMediaPresenter( + statusKey: MicroBlogKey, + initialIndex: Int, + context: Context, + scope: CoroutineScope = koinInject(), +) = run { + var showUi by remember { + mutableStateOf(false) + } + var withVideoPadding by remember { + mutableStateOf(false) + } + var showMenu by remember { + mutableStateOf(false) + } + val state = + remember(statusKey) { + StatusPresenter(statusKey) + }.invoke() + val medias = + state.status.map { + it.medias + } + var currentPage by remember { + mutableIntStateOf(initialIndex) + } + object : StatusState by state { + val medias = medias + val showUi = showUi + val withVideoPadding = withVideoPadding + val showMenu = showMenu + val currentPage = currentPage + + fun setShowUi(value: Boolean) { + showUi = value + } + + fun setWithVideoPadding(value: Boolean) { + withVideoPadding = value + } + + fun setShowMenu(value: Boolean) { + showMenu = value + } + + fun setCurrentPage(value: Int) { + currentPage = value + } + + fun save(uri: String) { + scope.launch { + context.imageLoader.diskCache?.openSnapshot(uri)?.use { + val byteArray = it.data.toFile().readBytes() + val fileName = uri.substringAfterLast("/") + saveByteArrayToDownloads(context, byteArray, fileName) + } + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.media_save_success), + Toast.LENGTH_SHORT, + ).show() + } } - object : StatusState by state { - val medias = medias } } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/media/VideoScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/media/VideoScreen.kt index 43afb2a4c..87c60695a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/media/VideoScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/media/VideoScreen.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.screen.media import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -10,7 +11,6 @@ import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.FULL_ROUTE_PLACEHOLDER import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import dev.dimension.flare.ui.common.FullScreenBox import dev.dimension.flare.ui.component.VideoPlayer import dev.dimension.flare.ui.theme.FlareTheme import moe.tlaster.swiper.Swiper @@ -31,6 +31,7 @@ internal fun VideoRoute( contentDescription: String?, navigator: DestinationsNavigator, ) { + SetDialogDestinationToEdgeToEdge() VideoScreen( uri = uri, previewUri = previewUri, @@ -53,7 +54,7 @@ internal fun VideoScreen( rememberSwiperState( onDismiss = onDismiss, ) - FullScreenBox( + Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/Bluesky.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/Bluesky.kt index af3bdd188..32a5ed460 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/Bluesky.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/Bluesky.kt @@ -32,6 +32,8 @@ internal fun BlueskyProfileHeader( user: UiUser.Bluesky, relationState: UiState, onFollowClick: (UiRelation.Bluesky) -> Unit, + onAvatarClick: () -> Unit, + onBannerClick: () -> Unit, isMe: UiState, menu: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, @@ -119,6 +121,8 @@ internal fun BlueskyProfileHeader( } }, modifier = modifier, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/Mastodon.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/Mastodon.kt index c5e642ced..964fbf93b 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/Mastodon.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/Mastodon.kt @@ -39,6 +39,8 @@ internal fun MastodonProfileHeader( user: UiUser.Mastodon, relationState: UiState, onFollowClick: (UiRelation.Mastodon) -> Unit, + onAvatarClick: () -> Unit, + onBannerClick: () -> Unit, isMe: UiState, menu: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, @@ -140,6 +142,8 @@ internal fun MastodonProfileHeader( } }, modifier = modifier, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/Misskey.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/Misskey.kt index e58cbc34f..74ecd0fe1 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/Misskey.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/Misskey.kt @@ -33,6 +33,8 @@ internal fun MisskeyProfileHeader( user: UiUser.Misskey, relationState: UiState, onFollowClick: (UiRelation.Misskey) -> Unit, + onAvatarClick: () -> Unit, + onBannerClick: () -> Unit, isMe: UiState, menu: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, @@ -135,6 +137,8 @@ internal fun MisskeyProfileHeader( } }, modifier = modifier, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileMediaScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileMediaScreen.kt index 819b991b8..886fca8ee 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileMediaScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileMediaScreen.kt @@ -38,6 +38,7 @@ import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.presenter.profile.ProfileMediaPresenter +import dev.dimension.flare.ui.screen.destinations.StatusMediaRouteDestination import dev.dimension.flare.ui.theme.screenHorizontalPadding @Composable @@ -51,12 +52,14 @@ internal fun ProfileMediaRoute( ProfileMediaScreen( userKey = userKey, onBack = navigator::navigateUp, - onItemClicked = { media -> - if (media is UiMedia.Image) { - navigator.navigate( - dev.dimension.flare.ui.screen.destinations.MediaRouteDestination(media.url), - ) - } + onItemClicked = { statusKey, index, preview -> + navigator.navigate( + StatusMediaRouteDestination( + statusKey = statusKey, + index = index, + preview = preview, + ), + ) }, ) } @@ -65,7 +68,7 @@ internal fun ProfileMediaRoute( @Composable private fun ProfileMediaScreen( userKey: MicroBlogKey?, - onItemClicked: (UiMedia) -> Unit, + onItemClicked: (statusKey: MicroBlogKey, index: Int, preview: String?) -> Unit, onBack: () -> Unit, ) { val state by producePresenter(key = userKey.toString()) { @@ -103,7 +106,24 @@ private fun ProfileMediaScreen( items(items.itemCount) { index -> val item = items[index] if (item != null) { - MediaItem(media = item, modifier = Modifier.clickable { onItemClicked(item) }) + val media = item.media + MediaItem( + media = media, + modifier = + Modifier + .clickable { + onItemClicked( + item.status.statusKey, + index, + when (media) { + is UiMedia.Image -> media.previewUrl + is UiMedia.Video -> media.thumbnailUrl + is UiMedia.Gif -> media.previewUrl + else -> null + }, + ) + }, + ) } else { Card { Box(modifier = Modifier.size(120.dp).placeholder(true)) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt index 4dd0baf68..ce6343802 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt @@ -103,6 +103,7 @@ import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.profile.ProfileMedia import dev.dimension.flare.ui.presenter.profile.ProfilePresenter import dev.dimension.flare.ui.presenter.profile.ProfileState import dev.dimension.flare.ui.presenter.profile.ProfileWithUserNameAndHostPresenter @@ -142,8 +143,19 @@ fun ProfileWithUserNameAndHostRoute( onBack = { navigator.navigateUp() }, + onProfileMediaClick = { + navigator.navigate( + dev.dimension.flare.ui.screen.destinations.ProfileMediaRouteDestination( + it.userKey, + ), + ) + }, onMediaClick = { - navigator.navigate(dev.dimension.flare.ui.screen.destinations.ProfileMediaRouteDestination(it.userKey)) + navigator.navigate( + dev.dimension.flare.ui.screen.destinations.MediaRouteDestination( + it, + ), + ) }, ) }.onLoading { @@ -274,8 +286,19 @@ fun ProfileRoute( onBack = { navigator.navigateUp() }, + onProfileMediaClick = { + navigator.navigate( + dev.dimension.flare.ui.screen.destinations.ProfileMediaRouteDestination( + userKey, + ), + ) + }, onMediaClick = { - navigator.navigate(dev.dimension.flare.ui.screen.destinations.ProfileMediaRouteDestination(userKey)) + navigator.navigate( + dev.dimension.flare.ui.screen.destinations.MediaRouteDestination( + it, + ), + ) }, ) } @@ -286,7 +309,8 @@ private fun ProfileScreen( // null means current user userKey: MicroBlogKey? = null, onBack: () -> Unit = {}, - onMediaClick: () -> Unit = {}, + onProfileMediaClick: () -> Unit = {}, + onMediaClick: (url: String) -> Unit = {}, showTopBar: Boolean = true, contentPadding: PaddingValues = PaddingValues(0.dp), ) { @@ -429,6 +453,16 @@ private fun ProfileScreen( ) }, expandMatrices = true, + onAvatarClick = { + state.state.userState.onSuccess { + onMediaClick(it.avatarUrl) + } + }, + onBannerClick = { + state.state.userState.onSuccess { + it.bannerUrl?.let { it1 -> onMediaClick(it1) } + } + }, ) } Card { @@ -437,7 +471,7 @@ private fun ProfileScreen( orientation = Orientation.Vertical, modifier = Modifier.clickable { - onMediaClick.invoke() + onProfileMediaClick.invoke() }, ) } @@ -471,6 +505,16 @@ private fun ProfileScreen( Spacer(modifier = Modifier.width(screenHorizontalPadding)) }, expandMatrices = false, + onAvatarClick = { + state.state.userState.onSuccess { + onMediaClick(it.avatarUrl) + } + }, + onBannerClick = { + state.state.userState.onSuccess { + it.bannerUrl?.let { it1 -> onMediaClick(it1) } + } + }, ) } item { @@ -479,7 +523,7 @@ private fun ProfileScreen( orientation = Orientation.Horizontal, modifier = Modifier.clickable { - onMediaClick.invoke() + onProfileMediaClick.invoke() }, ) } @@ -609,6 +653,8 @@ private fun ProfileHeader( userState: UiState, relationState: UiState, onFollowClick: (UiUser, UiRelation) -> Unit, + onAvatarClick: () -> Unit, + onBannerClick: () -> Unit, isMe: UiState, menu: @Composable RowScope.() -> Unit, expandMatrices: Boolean, @@ -646,6 +692,8 @@ private fun ProfileHeader( isMe = isMe, menu = menu, expandMatrices = expandMatrices, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } } @@ -657,6 +705,8 @@ private fun ProfileHeaderSuccess( user: UiUser, relationState: UiState, onFollowClick: (UiUser, UiRelation) -> Unit, + onAvatarClick: () -> Unit, + onBannerClick: () -> Unit, isMe: UiState, menu: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, @@ -674,6 +724,8 @@ private fun ProfileHeaderSuccess( }, menu = menu, expandMatrices = expandMatrices, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } @@ -688,6 +740,8 @@ private fun ProfileHeaderSuccess( }, menu = menu, expandMatrices = expandMatrices, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } @@ -702,6 +756,8 @@ private fun ProfileHeaderSuccess( }, menu = menu, expandMatrices = expandMatrices, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) is UiUser.XQT -> @@ -715,6 +771,8 @@ private fun ProfileHeaderSuccess( }, menu = menu, expandMatrices = expandMatrices, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } } @@ -726,6 +784,8 @@ internal fun CommonProfileHeader( displayName: Element, handle: String, modifier: Modifier = Modifier, + onAvatarClick: (() -> Unit)? = null, + onBannerClick: (() -> Unit)? = null, headerTrailing: @Composable RowScope.() -> Unit = {}, handleTrailing: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit = {}, @@ -751,7 +811,16 @@ internal fun CommonProfileHeader( modifier = Modifier .fillMaxWidth() - .height(actualBannerHeight), + .height(actualBannerHeight) + .let { + if (onBannerClick != null) { + it.clickable { + onBannerClick.invoke() + } + } else { + it + } + }, ) } ?: Box( modifier = @@ -781,7 +850,21 @@ internal fun CommonProfileHeader( top = (actualBannerHeight - ProfileHeaderConstants.AVATAR_SIZE.dp / 2), ), ) { - AvatarComponent(data = avatarUrl, size = ProfileHeaderConstants.AVATAR_SIZE.dp) + AvatarComponent( + data = avatarUrl, + size = ProfileHeaderConstants.AVATAR_SIZE.dp, + modifier = + Modifier + .let { + if (onAvatarClick != null) { + it.clickable { + onAvatarClick.invoke() + } + } else { + it + } + }, + ) } Column( modifier = @@ -905,7 +988,7 @@ internal fun ProfileHeaderLoading(modifier: Modifier = Modifier) { @Composable private fun ProfileMeidasPreview( - mediaState: UiState>, + mediaState: UiState>, orientation: Orientation, modifier: Modifier = Modifier, ) { @@ -922,13 +1005,14 @@ private fun ProfileMeidasPreview( if (item == null) { Box( modifier = - Modifier.aspectRatio(1f) + Modifier + .aspectRatio(1f) .placeholder(true), ) } else { Box { MediaItem( - media = item, + media = item.media, modifier = Modifier.fillMaxSize(), ) if (it == count - 1) { @@ -938,9 +1022,11 @@ private fun ProfileMeidasPreview( .matchParentSize() .background( color = - MaterialTheme.colorScheme.surfaceColorAtElevation( - 3.dp, - ).copy(alpha = 0.25f), + MaterialTheme.colorScheme + .surfaceColorAtElevation( + 3.dp, + ) + .copy(alpha = 0.25f), ), contentAlignment = Alignment.Center, ) { @@ -960,7 +1046,8 @@ private fun ProfileMeidasPreview( repeat(6) { Box( modifier = - Modifier.aspectRatio(1f) + Modifier + .aspectRatio(1f) .fillMaxSize() .placeholder(true), ) @@ -984,31 +1071,36 @@ private fun ProfileMeidasPreview( if (item == null) { Box( modifier = - Modifier.aspectRatio(1f) + Modifier + .aspectRatio(1f) .size(64.dp) .placeholder(true), ) } else { Box { MediaItem( - media = item, + media = item.media, modifier = - Modifier.size(64.dp).let { - if (item is UiMedia.Image && item.sensitive && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - ) { - it.blur(32.dp) - } else { - it - } - }, + Modifier + .size(64.dp) + .let { + if (item.media is UiMedia.Image && + (item.media as UiMedia.Image).sensitive && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ) { + it.blur(32.dp) + } else { + it + } + }, ) Box( modifier = Modifier .matchParentSize() .let { - if (item is UiMedia.Image && item.sensitive && + if (item.media is UiMedia.Image && + (item.media as UiMedia.Image).sensitive && Build.VERSION.SDK_INT < Build.VERSION_CODES.S ) { it.background(MaterialTheme.colorScheme.surfaceContainer) @@ -1024,9 +1116,11 @@ private fun ProfileMeidasPreview( .matchParentSize() .background( color = - MaterialTheme.colorScheme.surfaceColorAtElevation( - 3.dp, - ).copy(alpha = 0.25f), + MaterialTheme.colorScheme + .surfaceColorAtElevation( + 3.dp, + ) + .copy(alpha = 0.25f), ), contentAlignment = Alignment.Center, ) { @@ -1046,7 +1140,8 @@ private fun ProfileMeidasPreview( items(6) { Box( modifier = - Modifier.aspectRatio(1f) + Modifier + .aspectRatio(1f) .size(64.dp) .placeholder(true), ) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/XQT.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/XQT.kt index b5c7bbaea..a9e8f5619 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/XQT.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/XQT.kt @@ -41,6 +41,8 @@ internal fun XQTProfileHeader( user: UiUser.XQT, relationState: UiState, onFollowClick: (UiRelation.XQT) -> Unit, + onAvatarClick: () -> Unit, + onBannerClick: () -> Unit, isMe: UiState, menu: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, @@ -166,6 +168,8 @@ internal fun XQTProfileHeader( } }, modifier = modifier, + onAvatarClick = onAvatarClick, + onBannerClick = onBannerClick, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1086ec416..5530fb563 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -105,7 +105,7 @@ coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" apng = { group = "com.github.penfeizhou.android.animation", name = "apng", version.ref = "apng-android" } awebp = { group = "com.github.penfeizhou.android.animation", name = "awebp", version.ref = "apng-android" } vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version = "1.1.0" } -zoomable-image-coil = { group = "me.saket.telephoto", name = "zoomable-image-coil", version = "0.7.1" } +zoomable-image-coil = { group = "me.saket.telephoto", name = "zoomable", version = "0.7.1" } ktorfit-lib = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-lib", version.ref = "ktorfit" } ktorfit-ksp = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-ksp", version.ref = "ktorfit" } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfileMediaPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfileMediaPresenter.kt index c6f22b818..65b5ca9ca 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfileMediaPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfileMediaPresenter.kt @@ -9,6 +9,7 @@ import dev.dimension.flare.data.repository.activeAccountServicePresenter import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiStatus import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.medias import dev.dimension.flare.ui.presenter.PresenterBase @@ -24,9 +25,14 @@ class ProfileMediaPresenter( accountServiceState.map { (service, account) -> remember(account.accountKey, userKey) { service.userTimeline(userKey ?: account.accountKey, mediaOnly = true) - .map { - it.flatMap { - it.medias + .map { data -> + data.flatMap { status -> + status.medias.map { + ProfileMedia( + it, + status, + ) + } } } }.collectPagingProxy() @@ -38,5 +44,10 @@ class ProfileMediaPresenter( } interface ProfileMediaState { - val mediaState: UiState> + val mediaState: UiState> } + +data class ProfileMedia( + val media: UiMedia, + val status: UiStatus, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfilePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfilePresenter.kt index 698401545..0ebd149a1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfilePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/profile/ProfilePresenter.kt @@ -11,7 +11,6 @@ import dev.dimension.flare.data.datasource.misskey.MisskeyDataSource import dev.dimension.flare.data.datasource.xqt.XQTDataSource import dev.dimension.flare.data.repository.activeAccountServicePresenter import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.UiStatus @@ -210,7 +209,7 @@ class ProfilePresenter( abstract class ProfileState( val userState: UiState, val listState: UiState>, - val mediaState: UiState>, + val mediaState: UiState>, val relationState: UiState, val isMe: UiState, ) {