ui: improve Compose stability and message UI

- Add minute/second abbreviations and kotlin.time-based relative time formatter
- Introduce FastPreview and update previews to use AppTheme with dark/dynamic colors
- Refactor attachments preview grid & waveform to use ImmutableList and reduce recompositions
- Tweak message bubble reply styling and swipe-to-reply animation/haptics
- Add Compose Stability Analyzer plugin and enable it in debug builds
- Cache shared images by sha256 and improve share intent/chooser text
- Minor UX polish (e.g., “No views”) and immutability annotations
This commit is contained in:
2026-02-06 22:14:01 +03:00
parent e3e9157dd5
commit 96b4fc8539
30 changed files with 341 additions and 187 deletions
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -44,7 +45,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
@@ -53,9 +53,11 @@ import dev.meloda.fast.auth.captcha.CaptchaViewModel
import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import org.koin.androidx.compose.koinViewModel
@Composable
@@ -258,12 +260,14 @@ fun CaptchaScreen(
}
}
@Preview
@FastPreview
@Composable
private fun CaptchaScreenPreview() {
CaptchaScreen(
screenState = CaptchaScreenState.EMPTY.copy(
code = "zcuecz"
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
CaptchaScreen(
screenState = CaptchaScreenState.EMPTY.copy(
code = "zcuecz"
)
)
)
}
}
@@ -3,7 +3,6 @@ package dev.meloda.fast.auth.login.navigation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
@@ -3,6 +3,7 @@ package dev.meloda.fast.auth.validation.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -54,9 +55,11 @@ import dev.meloda.fast.auth.validation.ValidationViewModelImpl
import dev.meloda.fast.auth.validation.model.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import org.koin.androidx.compose.koinViewModel
@Composable
@@ -301,14 +304,16 @@ fun ValidationScreen(
}
}
@Preview
@FastPreview
@Composable
private fun ValidationScreenPreview() {
ValidationScreen(
screenState = ValidationScreenState.EMPTY.copy(
phoneMask = "+7 (***) ***-**-21",
code = "222222"
),
validationType = ValidationType.SMS
)
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
ValidationScreen(
screenState = ValidationScreenState.EMPTY.copy(
phoneMask = "+7 (***) ***-**-21",
code = "222222"
),
validationType = ValidationType.SMS
)
}
}
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -3,6 +3,7 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
@@ -30,7 +32,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.messageshistory.presentation.attachments.Attachments
@@ -38,14 +39,12 @@ import dev.meloda.fast.messageshistory.presentation.attachments.Reply
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkStickerDomain
import dev.meloda.fast.model.api.domain.VkVideoMessageDomain
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.darken
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isDark
import dev.meloda.fast.ui.util.lighten
@Composable
fun MessageBubble(
@@ -120,20 +119,22 @@ fun MessageBubble(
if (replyTitle != null) {
Reply(
modifier = Modifier
.padding(if (attachments == null || text != null) 0.dp else 4.dp)
.padding(if (attachments == null || text != null) 0.dp else 0.dp)
.width(with(density) { containerWidth.toDp() }),
bottomPadding = if (attachments == null || text != null) 0.dp else 4.dp,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (attachments == null || text != null) 0.dp else 16.dp,
bottomEnd = if (attachments == null || text != null) 0.dp else 16.dp
bottomStart = if (attachments == null || text != null) 0.dp else 0.dp,
bottomEnd = if (attachments == null || text != null) 0.dp else 0.dp
),
onClick = onReplyClick,
title = replyTitle,
summary = replySummary,
backgroundColor = colors.container,
innerBackgroundColor = colors.replyContainer
backgroundColor = colors.replyContainer,
innerBackgroundColor = colors.replyInnerContainer,
titleColor = colors.replyTitle,
textColor = colors.replyText
)
}
@@ -159,7 +160,7 @@ fun MessageBubble(
.padding(
start = 8.dp,
end = 8.dp,
top = if (replyTitle != null) 0.dp else 6.dp,
top = if (replyTitle != null) 4.dp else 6.dp,
bottom = if (replyTitle != null) 4.dp else 6.dp
)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
@@ -211,17 +212,25 @@ fun MessageBubble(
attachmentsContainerWidth = it.size.width
}
.clip(
if (!shouldShowBubble) RoundedCornerShape(24.dp)
else RoundedCornerShape(
if (!shouldShowBubble) {
RoundedCornerShape(
topStart = if (replyTitle != null) 0.dp else 24.dp,
topEnd = if (replyTitle != null) 0.dp else 24.dp,
bottomEnd = 24.dp,
bottomStart = 24.dp,
)
} else RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp,
bottomEnd = 24.dp,
bottomStart = 24.dp,
topStart = 0.dp,
topEnd = 0.dp
)
)
.background(attachmentBackgroundColor)
) {
Attachments(
withText = text != null,
withReply = replyTitle != null,
modifier = Modifier,
attachments = attachments,
onClick = currentOnClick,
@@ -261,6 +270,9 @@ private data class MessageBubbleColors(
val container: Color,
val content: Color,
val replyContainer: Color,
val replyInnerContainer: Color,
val replyTitle: Color,
val replyText: Color
)
@Composable
@@ -268,31 +280,35 @@ private fun messageBubbleColors(isOut: Boolean): MessageBubbleColors {
return if (isOut) {
val containerColor = MaterialTheme.colorScheme.primaryContainer
val replyContainerColor = if (containerColor.isDark()) {
containerColor.lighten(0.15f)
} else {
containerColor.darken(0.075f)
}
MessageBubbleColors(
container = containerColor,
content = MaterialTheme.colorScheme.onPrimaryContainer,
replyContainer = replyContainerColor
replyContainer = containerColor,
replyInnerContainer = MaterialTheme.colorScheme.background.copy(
if (isSystemInDarkTheme()) 0.3f else 0.45f
),
replyTitle = MaterialTheme.colorScheme.primary,
replyText = MaterialTheme.colorScheme.onBackground
)
} else {
val containerColor = MaterialTheme.colorScheme.surfaceContainer
MessageBubbleColors(
container = MaterialTheme.colorScheme.surfaceContainer,
container = containerColor,
content = MaterialTheme.colorScheme.onSurface,
replyContainer = MaterialTheme.colorScheme.surfaceContainerHighest
replyContainer = containerColor,
replyInnerContainer = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp),
replyTitle = MaterialTheme.colorScheme.primary,
replyText = MaterialTheme.colorScheme.onBackground
)
}
}
@Preview
@FastPreview
@Composable
private fun Bubble() {
AppTheme(
useDarkTheme = true,
useDarkTheme = isSystemInDarkTheme(),
useDynamicColors = true
) {
Column {
@@ -188,7 +188,9 @@ fun MessageOptionsDialog(
}
MessageOptionItem(
title = viewCount?.let { "$it views" } ?: "...",
title = viewCount?.let {
if (it == 0) "No views" else "$it views"
} ?: "...",
iconResId = R.drawable.ic_visibility_round_24,
tintColor = primaryColor,
onClick = {}
@@ -26,9 +26,12 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -40,7 +43,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
@@ -193,11 +195,22 @@ fun MessagesList(
}
)
val offsetX = remember { Animatable(0f) }
var animate by remember { mutableStateOf(false) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetDistinct by remember { mutableFloatStateOf(0f) }
val offsetAnimatable = remember { Animatable(0f) }
val offsetDistinct by snapshotFlow { offsetX.value }
.distinctUntilChanged()
.collectAsStateWithLifecycle(offsetX)
LaunchedEffect(offsetX) {
if (!animate) {
offsetAnimatable.snapTo(offsetX)
}
}
LaunchedEffect(Unit) {
snapshotFlow { offsetX.minus(5f).coerceIn(-100f, 0f) }
.distinctUntilChanged()
.collect { offsetDistinct = it }
}
LaunchedEffect(offsetDistinct) {
if (offsetDistinct == -100f && AppSettings.General.enableHaptic) {
@@ -222,32 +235,35 @@ fun MessagesList(
},
onClick = { onMessageClicked(item.id) }
)
.pointerInput(Unit) {
.pointerInput(item.cmId) {
detectHorizontalDragGestures(
onDragCancel = {
if (offsetX.value == -100f) {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
offsetX.animateTo(0f)
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onDragEnd = {
if (offsetX.value == -100f) {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
offsetX.animateTo(0f)
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onHorizontalDrag = { _, dragAmount ->
scope.launch {
offsetX.snapTo(
(offsetX.value + dragAmount).coerceIn(-100f, 0f)
)
}
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-100f, 0f)
}
)
},
@@ -278,7 +294,7 @@ fun MessagesList(
onRequestScrollToCmId(item.replyCmId!!)
}
},
offsetX = offsetX.value
offsetX = offsetAnimatable.value
)
} else {
IncomingMessageBubble(
@@ -305,7 +321,7 @@ fun MessagesList(
onRequestScrollToCmId(item.replyCmId!!)
}
},
offsetX = offsetX.value
offsetX = offsetAnimatable.value
)
}
}
@@ -16,7 +16,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,6 +52,8 @@ private val previewTypes = listOf(
@Composable
fun Attachments(
withText: Boolean,
withReply: Boolean,
modifier: Modifier = Modifier,
attachments: ImmutableList<out VkAttachment>,
onClick: (VkAttachment) -> Unit = {},
@@ -64,23 +65,20 @@ fun Attachments(
val currentOnLongClick by rememberUpdatedState(onLongClick)
Column(modifier = modifier) {
val previewAttachments by remember(attachments) {
derivedStateOf {
attachments.values.filter { it.type in previewTypes }
}
val previewAttachments = remember(attachments) {
attachments.values.filter { it.type in previewTypes }
}
val nonPreviewAttachments by remember(attachments) {
derivedStateOf {
attachments.values.filterNot { it.type in previewTypes }
.sortedBy { it.type.ordinal }
}
val nonPreviewAttachments = remember(attachments) {
attachments.values.filterNot { it.type in previewTypes }.sortedBy { it.type.ordinal }
}
if (previewAttachments.isNotEmpty()) {
Previews(
DynamicPreviewGrid(
withText = withText,
withReply = withReply,
modifier = Modifier,
photos = previewAttachments
previews = previewAttachments
.map(VkAttachment::asUiPhoto)
.toImmutableList(),
onClick = { index ->
@@ -187,7 +185,8 @@ fun Attachments(
.let(::downsampleWaveform)
.let(::downsampleWaveform)
.let { amplifyWaveform(it, audioMessage.waveform.max()) }
.map(::WaveForm),
.map(::WaveForm)
.toImmutableList(),
isPlaying = false,
onPlayClick = {}
)
@@ -26,11 +26,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.util.ImmutableList
import kotlin.collections.forEachIndexed
@Composable
fun AudioMessage(
waveform: List<WaveForm>,
waveform: ImmutableList<WaveForm>,
isPlaying: Boolean,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier,
@@ -1,6 +1,6 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -33,25 +33,12 @@ import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun Previews(
modifier: Modifier = Modifier,
photos: ImmutableList<UiPreview>,
onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {}
) {
DynamicPreviewGrid(
modifier = modifier,
photos = photos,
onClick = onClick,
onLongClick = onLongClick
)
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun DynamicPreviewGrid(
photos: ImmutableList<UiPreview>,
withText: Boolean,
withReply: Boolean,
previews: ImmutableList<UiPreview>,
modifier: Modifier = Modifier,
onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {}
@@ -60,16 +47,27 @@ fun DynamicPreviewGrid(
val currentOnLongClick by rememberUpdatedState(onLongClick)
val spacing = 2.dp
val shape = RoundedCornerShape(8.dp)
val cornerRadius = 20.dp
val insideRadius = 4.dp
BoxWithConstraints(modifier = modifier) {
val calculateShape by rememberUpdatedState { outer: Int, inner: Int, outLast: Int, inLast: Int ->
RoundedCornerShape(
topStart = if (!withText && !withReply && outer == 0 && inner == 0) cornerRadius else insideRadius,
topEnd = if (!withText && !withReply && outer == 0 && inner == inLast) cornerRadius else insideRadius,
bottomStart = if (outer == outLast && inner == 0) cornerRadius else insideRadius,
bottomEnd = if (outer == outLast && inner == inLast) cornerRadius else insideRadius
)
}
BoxWithConstraints(modifier = modifier.padding(4.dp)) {
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val spacingPx = with(LocalDensity.current) { spacing.toPx() }
val rows = photos.chunked(3)
val rows = previews.chunked(3)
Log.d("ROWS", "DynamicPreviewGrid: ${rows.size}")
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
rows.forEachIndexed { index, row ->
rows.forEachIndexed { outerIndex, row ->
val aspectRatios = row.map { it.width.toFloat() / it.height }
val totalAspect = aspectRatios.sum()
@@ -80,6 +78,8 @@ fun DynamicPreviewGrid(
val height = photoWidthPx / aspectRatios[index]
val heightDp = with(LocalDensity.current) { height.toDp() }
val shape = calculateShape(outerIndex, index, rows.lastIndex, row.lastIndex)
Box(
modifier = Modifier
.height(heightDp)
@@ -95,14 +95,14 @@ fun DynamicPreviewGrid(
.height(heightDp)
.clip(shape)
.combinedClickable(
onLongClick = { currentOnLongClick(index) },
onClick = { currentOnClick(index) }
onLongClick = { currentOnLongClick(outerIndex * 3 + index) },
onClick = { currentOnClick(outerIndex * 3 + index) }
)
)
if (preview.isVideo) {
FastIconButton(
onClick = { currentOnClick(index) },
onClick = { currentOnClick(outerIndex * 3 + index) },
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
@@ -146,6 +146,10 @@ fun PreviewDynamicPhotoGrid() {
.padding(8.dp),
contentAlignment = Alignment.Center
) {
DynamicPreviewGrid(photos = mockPhotos.take(10).toImmutableList())
DynamicPreviewGrid(
withText = false,
withReply = false,
previews = mockPhotos.take(10).toImmutableList()
)
}
}
@@ -3,6 +3,7 @@ package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -23,12 +24,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.domain.util.orEmpty
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
@Composable
fun Reply(
@@ -37,6 +41,8 @@ fun Reply(
shape: Shape,
backgroundColor: Color,
innerBackgroundColor: Color,
titleColor: Color,
textColor: Color,
title: String,
summary: AnnotatedString?,
modifier: Modifier = Modifier
@@ -47,7 +53,7 @@ fun Reply(
color = backgroundColor,
shape = shape
)
.height(40.dp)
.height(48.dp)
.padding(
top = 4.dp,
start = 4.dp,
@@ -66,7 +72,7 @@ fun Reply(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.onBackground)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(6.dp))
@@ -77,17 +83,22 @@ fun Reply(
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
color = titleColor
)
AnimatedVisibility(summary != null) {
Text(
text = summary.orEmpty(),
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp,
color = textColor
)
}
}
@@ -98,7 +109,9 @@ fun Reply(
@Composable
private fun ReplyBasePreview(
backgroundColor: Color,
innerBackgroundColor: Color
innerBackgroundColor: Color,
titleColor: Color,
textColor: Color
) {
Reply(
modifier = Modifier.width(120.dp),
@@ -111,24 +124,42 @@ private fun ReplyBasePreview(
summary = "2 photos".annotated(),
backgroundColor = backgroundColor,
innerBackgroundColor = innerBackgroundColor,
bottomPadding = 0.dp
titleColor = titleColor,
textColor = textColor,
bottomPadding = 0.dp,
)
}
@Preview
@FastPreview
@Composable
private fun IncomingReplyPreview() {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp)
)
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp),
titleColor = MaterialTheme.colorScheme.primary,
textColor = MaterialTheme.colorScheme.onBackground
)
}
}
@Preview
@FastPreview
@Composable
private fun OutgoingReplyPreview() {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
innerBackgroundColor = MaterialTheme.colorScheme.inversePrimary
)
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
val bg = MaterialTheme.colorScheme.primaryContainer
val inner = MaterialTheme.colorScheme.background.copy(
if (isSystemInDarkTheme()) 0.3f else 0.6f
)
val title = MaterialTheme.colorScheme.primary
val text = MaterialTheme.colorScheme.onBackground
ReplyBasePreview(
backgroundColor = bg,
innerBackgroundColor = inner,
titleColor = title,
textColor = text
)
}
}
@@ -5,7 +5,6 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull
@@ -17,9 +16,11 @@ import coil.imageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.util.sha256
import dev.meloda.fast.photoviewer.model.PhotoViewArguments
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.photoviewer.navigation.PhotoView
import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -28,9 +29,6 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.URLDecoder
import java.util.UUID
import dev.meloda.fast.ui.R
interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState>
@@ -99,9 +97,10 @@ class PhotoViewViewModelImpl(
type = "image/png"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(intent, null)
val chooserIntent = Intent.createChooser(intent, "Share image via...")
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
chooserIntent
}
@@ -186,6 +185,13 @@ class PhotoViewViewModelImpl(
private suspend fun downloadAndStoreImageToCache(url: String): File? =
runCatching {
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) {
imagesDir.mkdirs()
}
val imageFile = File(imagesDir, "${url.sha256()}.png")
if (imageFile.exists()) return imageFile
withContext(Dispatchers.IO) {
screenState.setValue { old -> old.copy(isLoading = true) }
@@ -198,9 +204,6 @@ class PhotoViewViewModelImpl(
return@withContext null
}
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${UUID.randomUUID()}.png")
FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
}