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,8 @@ import android.app.Application
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings
import org.koin.android.ext.android.get
@@ -20,6 +22,8 @@ class AppGlobal : Application(), ImageLoaderFactory {
AppSettings.init(preferences)
initKoin()
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
}
private fun initKoin() {
@@ -69,9 +69,7 @@ import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.immutableListOf
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@@ -309,7 +307,7 @@ fun RootScreen(
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<ImmutableList<String>, Int?>?>(null)
mutableStateOf<Pair<List<String>, Int?>?>(null)
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -333,7 +331,7 @@ fun RootScreen(
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
photoViewerInfo = listOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
@@ -344,13 +342,13 @@ fun RootScreen(
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos.toImmutableList() to index
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
photoViewerInfo = listOf(url) to null
}
)
createChatScreen(
@@ -378,7 +376,9 @@ fun RootScreen(
}
PhotoViewDialog(
photoViewerInfo = photoViewerInfo,
photoViewerInfo = photoViewerInfo?.let { info ->
info.first.toImmutableList() to info.second
},
onDismiss = { photoViewerInfo = null }
)
}
@@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
@@ -10,6 +10,7 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>()
extension.androidResources.enable = false
+1
View File
@@ -9,4 +9,5 @@ plugins {
alias(libs.plugins.ksp) apply false
alias(libs.plugins.module.graph) apply true
alias(libs.plugins.versions) apply true
alias(libs.plugins.stability.analyzer) apply false
}
@@ -1,7 +1,6 @@
package dev.meloda.fast.common.util
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay
import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute
@@ -12,6 +11,12 @@ import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
object TimeUtils {
@@ -56,37 +61,23 @@ object TimeUtils {
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String
): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
val now = Clock.System.now()
val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
return when {
now.year != then.year -> {
"${now.year - then.year}${yearShort().lowercase()}"
}
now.month != then.month -> {
"${now.month - then.month}${monthShort().lowercase()}"
}
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${weekShort().lowercase()}"
} else {
"$change${dayShort().lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
now().lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}"
diff > 30.days -> "${diff.inWholeDays / 30}${monthShort().lowercase()}"
diff > 7.days -> "${diff.inWholeDays / 7}${weekShort().lowercase()}"
diff > 1.days -> "${diff.inWholeDays}${dayShort().lowercase()}"
diff > 1.hours -> "${diff.inWholeHours}h"
diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}"
diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}"
else -> now().lowercase()
}
}
}
@@ -1,7 +1,17 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
import java.security.MessageDigest
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
fun String.sha256() = this.hashString("SHA-256")
fun String.hashString(algorithm: String): String {
return MessageDigest
.getInstance(algorithm)
.digest(this.toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
}
@@ -6,10 +6,7 @@ import dev.meloda.fast.model.database.AccountEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase(
private val accountsRepository: AccountsRepository
) {
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId)
}
@@ -27,6 +27,8 @@ fun VkConvo.asPresentation(
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
minuteShort = { resources.getString(R.string.minute_short) },
secondShort = { resources.getString(R.string.second_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
@@ -1,7 +1,9 @@
package dev.meloda.fast.model.api.domain
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.data.AttachmentType
@Immutable
interface VkAttachment {
val type: AttachmentType
}
@@ -0,0 +1,48 @@
package dev.meloda.fast.ui.common
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_TYPE_NORMAL
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.BLUE_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.YELLOW_DOMINATED_EXAMPLE
@Preview(name = "70%", fontScale = 0.70f)
@Preview(name = "85%", fontScale = 0.85f)
@Preview(name = "100%", fontScale = 1.0f)
@Preview(name = "115%", fontScale = 1.15f)
@Preview(name = "130%", fontScale = 1.3f)
@Preview(name = "150%", fontScale = 1.5f)
@Preview(name = "180%", fontScale = 1.8f)
@Preview(name = "200%", fontScale = 2f)
@Preview(name = "Light")
@Preview(name = "Red", wallpaper = RED_DOMINATED_EXAMPLE)
@Preview(name = "Blue", wallpaper = BLUE_DOMINATED_EXAMPLE)
@Preview(name = "Green", wallpaper = GREEN_DOMINATED_EXAMPLE)
@Preview(name = "Yellow", wallpaper = YELLOW_DOMINATED_EXAMPLE)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
@Preview(
name = "Dark Red",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = RED_DOMINATED_EXAMPLE
)
@Preview(
name = "Dark Blue",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = BLUE_DOMINATED_EXAMPLE
)
@Preview(
name = "Dark Green",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = GREEN_DOMINATED_EXAMPLE
)
@Preview(
name = "Dark Yellow",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = YELLOW_DOMINATED_EXAMPLE
)
annotation class FastPreview
@@ -2,6 +2,7 @@ package dev.meloda.fast.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -46,9 +47,14 @@ import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewDynamicColors
import androidx.compose.ui.tooling.preview.PreviewFontScale
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@@ -361,10 +367,10 @@ sealed class SelectionType {
data object None : SelectionType()
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogPreview() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(
onDismissRequest = {},
title = "Material Dialog",
@@ -376,10 +382,10 @@ private fun MaterialDialogPreview() {
}
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogWithListPreview() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(
onDismissRequest = {},
title = "Material Dialog",
@@ -393,10 +399,10 @@ private fun MaterialDialogWithListPreview() {
}
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogWithCustomContent() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(
onDismissRequest = {},
title = "Material Dialog",
@@ -425,10 +431,10 @@ private fun MaterialDialogWithCustomContent() {
}
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogWithOnlyCustomContent() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(onDismissRequest = {}) {
Row(
modifier = Modifier
@@ -1,5 +1,6 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -8,15 +9,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
@Composable
fun NoItemsView(
@@ -49,11 +52,15 @@ fun NoItemsView(
}
}
@Preview
@FastPreview
@Composable
private fun NoItemsViewPreview() {
NoItemsView(
customText = "Nothing here...",
buttonText = "Refresh"
)
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
Surface {
NoItemsView(
customText = "Nothing here...",
buttonText = "Refresh"
)
}
}
}
@@ -9,6 +9,7 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.ParametersDefinition
import org.koin.core.qualifier.Qualifier
@Suppress("ParamsComparedByRef")
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
navController: NavController,
@@ -9,10 +9,6 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
operator fun get(index: Int): T = values[index]
inline fun forEach(action: (T) -> Unit) {
for (element in values) action(element)
}
inline fun <R> map(transform: (T) -> R): ImmutableList<R> {
return values.map(transform).toImmutableList()
}
@@ -49,6 +45,8 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
if (elements.isNotEmpty()) copyOf(elements.asList()) else empty()
fun <T> of(element: T) = ImmutableList(listOf(element))
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
}
override fun iterator(): Iterator<T> = values.listIterator()
@@ -59,5 +57,3 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
@@ -232,8 +232,10 @@
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="second_short">С</string>
<string name="time_now">Сейчас</string>
<string name="confirm_chat_create_with_title">Вы действительно хотите создать чат «%s»?</string>
<string name="confirm_chat_create_empty_with_title">Вы действительно хотите создать чат «%s» только с собой?</string>
<string name="minute_short">М</string>
</resources>
+3 -1
View File
@@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<string name="app_name" translatable="false">Fast</string>
<string name="fast_messenger" translatable="false">Fast Messenger</string>
@@ -297,6 +297,8 @@
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="minute_short">M</string>
<string name="second_short">S</string>
<string name="time_now">Now</string>
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</string>
@@ -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)
}
+4 -2
View File
@@ -3,12 +3,13 @@ agp = "9.0.0"
retrofit = "3.0.0"
eithernet = "2.0.0"
haze = "1.7.1"
kotlin = "2.3.0"
kotlin = "2.3.10"
ksp = "2.3.4"
moduleGraph = "2.9.0"
versions = "0.53.0"
stability-analyzer = "0.6.6"
compose-bom = "2026.01.00"
compose-bom = "2026.01.01"
koin = "4.1.1"
accompanist = "0.37.3"
@@ -117,6 +118,7 @@ room = { id = "androidx.room", version.ref = "room" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
module-graph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" }
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stability-analyzer" }
#project plugins
fast-android-application = { id = "fast.android.application", version = "unspecified" }