2 Commits

Author SHA1 Message Date
melod1n ce375c902c Refactor: Improve reply component layout and styling
This commit refactors the `Reply` composable for better layout consistency and simplifies its implementation.

The `Reply` component no longer uses a fixed height, allowing it to dynamically resize based on its content. The layout has been updated from a `Box` to a `Row` to properly align the side indicator bar with the height of the text content. Padding and corner rounding logic has been simplified and centralized within the `Reply` composable itself, removing redundant parameters from the `MessageBubble`.

Key changes:
- `Reply` composable now uses `Row` for its root layout instead of `Box`.
- Removed the fixed `48.dp` height to allow dynamic content sizing.
- The side indicator bar's height now matches the text content's height.
- Simplified padding and shape logic in `MessageBubble` by removing conditional parameters passed to `Reply`.
- Adjusted padding inside `MessageBubble` to accommodate the new `Reply` layout.
2026-02-06 22:58:03 +03:00
melod1n 96b4fc8539 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
2026-02-06 22:14:01 +03:00
30 changed files with 366 additions and 224 deletions
@@ -4,6 +4,8 @@ import android.app.Application
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory 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.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
@@ -20,6 +22,8 @@ class AppGlobal : Application(), ImageLoaderFactory {
AppSettings.init(preferences) AppSettings.init(preferences)
initKoin() initKoin()
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
} }
private fun initKoin() { 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.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser 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.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.immutableListOf
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -309,7 +307,7 @@ fun RootScreen(
LocalNavController provides navController LocalNavController provides navController
) { ) {
var photoViewerInfo by rememberSaveable { var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<ImmutableList<String>, Int?>?>(null) mutableStateOf<Pair<List<String>, Int?>?>(null)
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -333,7 +331,7 @@ fun RootScreen(
onSettingsButtonClicked = navController::navigateToSettings, onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory, onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null photoViewerInfo = listOf(url) to null
}, },
onMessageClicked = navController::navigateToMessagesHistory, onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat onNavigateToCreateChat = navController::navigateToCreateChat
@@ -344,13 +342,13 @@ fun RootScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials, onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index -> onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos.toImmutableList() to index photoViewerInfo = photos to index
} }
) )
chatMaterialsScreen( chatMaterialsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onPhotoClicked = { url -> onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null photoViewerInfo = listOf(url) to null
} }
) )
createChatScreen( createChatScreen(
@@ -378,7 +376,9 @@ fun RootScreen(
} }
PhotoViewDialog( PhotoViewDialog(
photoViewerInfo = photoViewerInfo, photoViewerInfo = photoViewerInfo?.let { info ->
info.first.toImmutableList() to info.second
},
onDismiss = { photoViewerInfo = null } onDismiss = { photoViewerInfo = null }
) )
} }
@@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
with(target) { with(target) {
apply(plugin = "com.android.application") apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<ApplicationExtension>() val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension) configureAndroidCompose(extension)
@@ -10,6 +10,7 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
with(target) { with(target) {
apply(plugin = "com.android.library") apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>() val extension = extensions.getByType<LibraryExtension>()
extension.androidResources.enable = false extension.androidResources.enable = false
+1
View File
@@ -9,4 +9,5 @@ plugins {
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.module.graph) apply true alias(libs.plugins.module.graph) apply true
alias(libs.plugins.versions) 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 package dev.meloda.fast.common.util
import com.conena.nanokt.jvm.util.dayOfMonth 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.hourOfDay
import com.conena.nanokt.jvm.util.millisecond import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute import com.conena.nanokt.jvm.util.minute
@@ -12,6 +11,12 @@ import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale 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 { object TimeUtils {
@@ -56,37 +61,23 @@ object TimeUtils {
monthShort: () -> String, monthShort: () -> String,
weekShort: () -> String, weekShort: () -> String,
dayShort: () -> String, dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String now: () -> String
): String { ): String {
val now = Calendar.getInstance() val now = Clock.System.now()
val then = Calendar.getInstance().also { it.timeInMillis = date } val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
return when { return when {
now.year != then.year -> { diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}"
"${now.year - then.year}${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()}"
now.month != then.month -> { diff > 1.hours -> "${diff.inWholeHours}h"
"${now.month - then.month}${monthShort().lowercase()}" diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}"
} diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}"
else -> now().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)
}
} }
} }
} }
@@ -1,7 +1,17 @@
package dev.meloda.fast.common.util package dev.meloda.fast.common.util
import java.net.URLEncoder import java.net.URLEncoder
import java.security.MessageDigest
fun String.urlEncode(encoding: String = "utf-8"): String { fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding) 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase( class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
private val accountsRepository: AccountsRepository
) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) { suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId) accountsRepository.getAccountById(UserConfig.currentUserId)
} }
@@ -27,6 +27,8 @@ fun VkConvo.asPresentation(
monthShort = { resources.getString(R.string.month_short) }, monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) }, weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_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) }, now = { resources.getString(R.string.time_now) },
), ),
message = extractMessage(resources, lastMessage, id, peerType), message = extractMessage(resources, lastMessage, id, peerType),
@@ -1,7 +1,9 @@
package dev.meloda.fast.model.api.domain package dev.meloda.fast.model.api.domain
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
@Immutable
interface VkAttachment { interface VkAttachment {
val type: AttachmentType 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.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope 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.res.vectorResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview 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.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R 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.theme.AppTheme
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@@ -361,10 +367,10 @@ sealed class SelectionType {
data object None : SelectionType() data object None : SelectionType()
} }
@Preview @FastPreview
@Composable @Composable
private fun MaterialDialogPreview() { private fun MaterialDialogPreview() {
AppTheme { AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog( MaterialDialog(
onDismissRequest = {}, onDismissRequest = {},
title = "Material Dialog", title = "Material Dialog",
@@ -376,10 +382,10 @@ private fun MaterialDialogPreview() {
} }
} }
@Preview @FastPreview
@Composable @Composable
private fun MaterialDialogWithListPreview() { private fun MaterialDialogWithListPreview() {
AppTheme { AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog( MaterialDialog(
onDismissRequest = {}, onDismissRequest = {},
title = "Material Dialog", title = "Material Dialog",
@@ -393,10 +399,10 @@ private fun MaterialDialogWithListPreview() {
} }
} }
@Preview @FastPreview
@Composable @Composable
private fun MaterialDialogWithCustomContent() { private fun MaterialDialogWithCustomContent() {
AppTheme { AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog( MaterialDialog(
onDismissRequest = {}, onDismissRequest = {},
title = "Material Dialog", title = "Material Dialog",
@@ -425,10 +431,10 @@ private fun MaterialDialogWithCustomContent() {
} }
} }
@Preview @FastPreview
@Composable @Composable
private fun MaterialDialogWithOnlyCustomContent() { private fun MaterialDialogWithOnlyCustomContent() {
AppTheme { AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(onDismissRequest = {}) { MaterialDialog(onDismissRequest = {}) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -1,5 +1,6 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
@Composable @Composable
fun NoItemsView( fun NoItemsView(
@@ -49,11 +52,15 @@ fun NoItemsView(
} }
} }
@Preview @FastPreview
@Composable @Composable
private fun NoItemsViewPreview() { private fun NoItemsViewPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
Surface {
NoItemsView( NoItemsView(
customText = "Nothing here...", customText = "Nothing here...",
buttonText = "Refresh" buttonText = "Refresh"
) )
} }
}
}
@@ -9,6 +9,7 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.ParametersDefinition import org.koin.core.parameter.ParametersDefinition
import org.koin.core.qualifier.Qualifier import org.koin.core.qualifier.Qualifier
@Suppress("ParamsComparedByRef")
@Composable @Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel( inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
navController: NavController, navController: NavController,
@@ -9,10 +9,6 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
operator fun get(index: Int): T = values[index] 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> { inline fun <R> map(transform: (T) -> R): ImmutableList<R> {
return values.map(transform).toImmutableList() 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() if (elements.isNotEmpty()) copyOf(elements.asList()) else empty()
fun <T> of(element: T) = ImmutableList(listOf(element)) fun <T> of(element: T) = ImmutableList(listOf(element))
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
} }
override fun iterator(): Iterator<T> = values.listIterator() 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> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) 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="month_short">М</string>
<string name="week_short">Н</string> <string name="week_short">Н</string>
<string name="day_short">Д</string> <string name="day_short">Д</string>
<string name="second_short">С</string>
<string name="time_now">Сейчас</string> <string name="time_now">Сейчас</string>
<string name="confirm_chat_create_with_title">Вы действительно хотите создать чат «%s»?</string> <string name="confirm_chat_create_with_title">Вы действительно хотите создать чат «%s»?</string>
<string name="confirm_chat_create_empty_with_title">Вы действительно хотите создать чат «%s» только с собой?</string> <string name="confirm_chat_create_empty_with_title">Вы действительно хотите создать чат «%s» только с собой?</string>
<string name="minute_short">М</string>
</resources> </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="app_name" translatable="false">Fast</string>
<string name="fast_messenger" translatable="false">Fast Messenger</string> <string name="fast_messenger" translatable="false">Fast Messenger</string>
@@ -297,6 +297,8 @@
<string name="month_short">M</string> <string name="month_short">M</string>
<string name="week_short">W</string> <string name="week_short">W</string>
<string name="day_short">D</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="time_now">Now</string>
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</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.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner 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.CaptchaViewModelImpl
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
import dev.meloda.fast.ui.R 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.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
@@ -258,12 +260,14 @@ fun CaptchaScreen(
} }
} }
@Preview @FastPreview
@Composable @Composable
private fun CaptchaScreenPreview() { private fun CaptchaScreenPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
CaptchaScreen( CaptchaScreen(
screenState = CaptchaScreenState.EMPTY.copy( screenState = CaptchaScreenState.EMPTY.copy(
code = "zcuecz" code = "zcuecz"
) )
) )
} }
}
@@ -3,7 +3,6 @@ package dev.meloda.fast.auth.login.navigation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
@@ -3,6 +3,7 @@ package dev.meloda.fast.auth.validation.presentation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType import dev.meloda.fast.auth.validation.model.ValidationType
import dev.meloda.fast.ui.R 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.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
@@ -301,9 +304,10 @@ fun ValidationScreen(
} }
} }
@Preview @FastPreview
@Composable @Composable
private fun ValidationScreenPreview() { private fun ValidationScreenPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
ValidationScreen( ValidationScreen(
screenState = ValidationScreenState.EMPTY.copy( screenState = ValidationScreenState.EMPTY.copy(
phoneMask = "+7 (***) ***-**-21", phoneMask = "+7 (***) ***-**-21",
@@ -312,3 +316,4 @@ private fun ValidationScreenPreview() {
validationType = ValidationType.SMS validationType = ValidationType.SMS
) )
} }
}
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi 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.animateContentSize
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.domain.util.annotated import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.messageshistory.presentation.attachments.Attachments 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.VkAttachment
import dev.meloda.fast.model.api.domain.VkStickerDomain import dev.meloda.fast.model.api.domain.VkStickerDomain
import dev.meloda.fast.model.api.domain.VkVideoMessageDomain 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.model.vk.SendingStatus
import dev.meloda.fast.ui.theme.AppTheme import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList 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.emptyImmutableList
import dev.meloda.fast.ui.util.isDark
import dev.meloda.fast.ui.util.lighten
@Composable @Composable
fun MessageBubble( fun MessageBubble(
@@ -119,21 +118,18 @@ fun MessageBubble(
) { ) {
if (replyTitle != null) { if (replyTitle != null) {
Reply( Reply(
modifier = Modifier modifier = Modifier.width(with(density) { containerWidth.toDp() }),
.padding(if (attachments == null || text != null) 0.dp else 4.dp)
.width(with(density) { containerWidth.toDp() }),
bottomPadding = if (attachments == null || text != null) 0.dp else 4.dp,
shape = RoundedCornerShape( shape = RoundedCornerShape(
topStart = 16.dp, topStart = 16.dp,
topEnd = 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
), ),
onClick = onReplyClick, onClick = onReplyClick,
title = replyTitle, title = replyTitle,
summary = replySummary, summary = replySummary,
backgroundColor = colors.container, backgroundColor = colors.replyContainer,
innerBackgroundColor = colors.replyContainer innerBackgroundColor = colors.replyInnerContainer,
titleColor = colors.replyTitle,
textColor = colors.replyText
) )
} }
@@ -211,17 +207,25 @@ fun MessageBubble(
attachmentsContainerWidth = it.size.width attachmentsContainerWidth = it.size.width
} }
.clip( .clip(
if (!shouldShowBubble) RoundedCornerShape(24.dp) if (!shouldShowBubble) {
else RoundedCornerShape( RoundedCornerShape(
topStart = if (replyTitle != null) 0.dp else 24.dp,
topEnd = if (replyTitle != null) 0.dp else 24.dp,
bottomEnd = 24.dp, bottomEnd = 24.dp,
bottomStart = 24.dp, bottomStart = 24.dp,
)
} else RoundedCornerShape(
topStart = 0.dp, topStart = 0.dp,
topEnd = 0.dp topEnd = 0.dp,
bottomEnd = 24.dp,
bottomStart = 24.dp,
) )
) )
.background(attachmentBackgroundColor) .background(attachmentBackgroundColor)
) { ) {
Attachments( Attachments(
withText = text != null,
withReply = replyTitle != null,
modifier = Modifier, modifier = Modifier,
attachments = attachments, attachments = attachments,
onClick = currentOnClick, onClick = currentOnClick,
@@ -261,6 +265,9 @@ private data class MessageBubbleColors(
val container: Color, val container: Color,
val content: Color, val content: Color,
val replyContainer: Color, val replyContainer: Color,
val replyInnerContainer: Color,
val replyTitle: Color,
val replyText: Color
) )
@Composable @Composable
@@ -268,31 +275,35 @@ private fun messageBubbleColors(isOut: Boolean): MessageBubbleColors {
return if (isOut) { return if (isOut) {
val containerColor = MaterialTheme.colorScheme.primaryContainer val containerColor = MaterialTheme.colorScheme.primaryContainer
val replyContainerColor = if (containerColor.isDark()) {
containerColor.lighten(0.15f)
} else {
containerColor.darken(0.075f)
}
MessageBubbleColors( MessageBubbleColors(
container = containerColor, container = containerColor,
content = MaterialTheme.colorScheme.onPrimaryContainer, 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 { } else {
val containerColor = MaterialTheme.colorScheme.surfaceContainer
MessageBubbleColors( MessageBubbleColors(
container = MaterialTheme.colorScheme.surfaceContainer, container = containerColor,
content = MaterialTheme.colorScheme.onSurface, 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 @Composable
private fun Bubble() { private fun Bubble() {
AppTheme( AppTheme(
useDarkTheme = true, useDarkTheme = isSystemInDarkTheme(),
useDynamicColors = true useDynamicColors = true
) { ) {
Column { Column {
@@ -188,7 +188,9 @@ fun MessageOptionsDialog(
} }
MessageOptionItem( MessageOptionItem(
title = viewCount?.let { "$it views" } ?: "...", title = viewCount?.let {
if (it == 0) "No views" else "$it views"
} ?: "...",
iconResId = R.drawable.ic_visibility_round_24, iconResId = R.drawable.ic_visibility_round_24,
tintColor = primaryColor, tintColor = primaryColor,
onClick = {} onClick = {}
@@ -26,9 +26,12 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -40,7 +43,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings 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 } LaunchedEffect(offsetX) {
if (!animate) {
offsetAnimatable.snapTo(offsetX)
}
}
LaunchedEffect(Unit) {
snapshotFlow { offsetX.minus(5f).coerceIn(-100f, 0f) }
.distinctUntilChanged() .distinctUntilChanged()
.collectAsStateWithLifecycle(offsetX) .collect { offsetDistinct = it }
}
LaunchedEffect(offsetDistinct) { LaunchedEffect(offsetDistinct) {
if (offsetDistinct == -100f && AppSettings.General.enableHaptic) { if (offsetDistinct == -100f && AppSettings.General.enableHaptic) {
@@ -222,32 +235,35 @@ fun MessagesList(
}, },
onClick = { onMessageClicked(item.id) } onClick = { onMessageClicked(item.id) }
) )
.pointerInput(Unit) { .pointerInput(item.cmId) {
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragCancel = { onDragCancel = {
if (offsetX.value == -100f) { if (offsetX == -100f) {
onRequestMessageReply(item.cmId) onRequestMessageReply(item.cmId)
} }
scope.launch { scope.launch {
offsetX.animateTo(0f) animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
} }
}, },
onDragEnd = { onDragEnd = {
if (offsetX.value == -100f) { if (offsetX == -100f) {
onRequestMessageReply(item.cmId) onRequestMessageReply(item.cmId)
} }
scope.launch { scope.launch {
offsetX.animateTo(0f) animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
} }
}, },
onHorizontalDrag = { _, dragAmount -> onHorizontalDrag = { change, dragAmount ->
scope.launch { change.consume()
offsetX.snapTo( offsetX = (offsetX + dragAmount).coerceIn(-100f, 0f)
(offsetX.value + dragAmount).coerceIn(-100f, 0f)
)
}
} }
) )
}, },
@@ -278,7 +294,7 @@ fun MessagesList(
onRequestScrollToCmId(item.replyCmId!!) onRequestScrollToCmId(item.replyCmId!!)
} }
}, },
offsetX = offsetX.value offsetX = offsetAnimatable.value
) )
} else { } else {
IncomingMessageBubble( IncomingMessageBubble(
@@ -305,7 +321,7 @@ fun MessagesList(
onRequestScrollToCmId(item.replyCmId!!) 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -53,6 +52,8 @@ private val previewTypes = listOf(
@Composable @Composable
fun Attachments( fun Attachments(
withText: Boolean,
withReply: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
attachments: ImmutableList<out VkAttachment>, attachments: ImmutableList<out VkAttachment>,
onClick: (VkAttachment) -> Unit = {}, onClick: (VkAttachment) -> Unit = {},
@@ -64,23 +65,20 @@ fun Attachments(
val currentOnLongClick by rememberUpdatedState(onLongClick) val currentOnLongClick by rememberUpdatedState(onLongClick)
Column(modifier = modifier) { Column(modifier = modifier) {
val previewAttachments by remember(attachments) { val previewAttachments = remember(attachments) {
derivedStateOf {
attachments.values.filter { it.type in previewTypes } attachments.values.filter { it.type in previewTypes }
} }
}
val nonPreviewAttachments by remember(attachments) { val nonPreviewAttachments = remember(attachments) {
derivedStateOf { attachments.values.filterNot { it.type in previewTypes }.sortedBy { it.type.ordinal }
attachments.values.filterNot { it.type in previewTypes }
.sortedBy { it.type.ordinal }
}
} }
if (previewAttachments.isNotEmpty()) { if (previewAttachments.isNotEmpty()) {
Previews( DynamicPreviewGrid(
withText = withText,
withReply = withReply,
modifier = Modifier, modifier = Modifier,
photos = previewAttachments previews = previewAttachments
.map(VkAttachment::asUiPhoto) .map(VkAttachment::asUiPhoto)
.toImmutableList(), .toImmutableList(),
onClick = { index -> onClick = { index ->
@@ -187,7 +185,8 @@ fun Attachments(
.let(::downsampleWaveform) .let(::downsampleWaveform)
.let(::downsampleWaveform) .let(::downsampleWaveform)
.let { amplifyWaveform(it, audioMessage.waveform.max()) } .let { amplifyWaveform(it, audioMessage.waveform.max()) }
.map(::WaveForm), .map(::WaveForm)
.toImmutableList(),
isPlaying = false, isPlaying = false,
onPlayClick = {} onPlayClick = {}
) )
@@ -26,11 +26,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times import androidx.compose.ui.unit.times
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FastIconButton import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.util.ImmutableList
import kotlin.collections.forEachIndexed import kotlin.collections.forEachIndexed
@Composable @Composable
fun AudioMessage( fun AudioMessage(
waveform: List<WaveForm>, waveform: ImmutableList<WaveForm>,
isPlaying: Boolean, isPlaying: Boolean,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -1,6 +1,6 @@
package dev.meloda.fast.messageshistory.presentation.attachments package dev.meloda.fast.messageshistory.presentation.attachments
import android.annotation.SuppressLint import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement 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
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList 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 @Composable
fun DynamicPreviewGrid( fun DynamicPreviewGrid(
photos: ImmutableList<UiPreview>, withText: Boolean,
withReply: Boolean,
previews: ImmutableList<UiPreview>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (index: Int) -> Unit = {}, onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {} onLongClick: (index: Int) -> Unit = {}
@@ -60,16 +47,27 @@ fun DynamicPreviewGrid(
val currentOnLongClick by rememberUpdatedState(onLongClick) val currentOnLongClick by rememberUpdatedState(onLongClick)
val spacing = 2.dp 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 maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val spacingPx = with(LocalDensity.current) { spacing.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)) { Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
rows.forEachIndexed { index, row -> rows.forEachIndexed { outerIndex, row ->
val aspectRatios = row.map { it.width.toFloat() / it.height } val aspectRatios = row.map { it.width.toFloat() / it.height }
val totalAspect = aspectRatios.sum() val totalAspect = aspectRatios.sum()
@@ -80,6 +78,8 @@ fun DynamicPreviewGrid(
val height = photoWidthPx / aspectRatios[index] val height = photoWidthPx / aspectRatios[index]
val heightDp = with(LocalDensity.current) { height.toDp() } val heightDp = with(LocalDensity.current) { height.toDp() }
val shape = calculateShape(outerIndex, index, rows.lastIndex, row.lastIndex)
Box( Box(
modifier = Modifier modifier = Modifier
.height(heightDp) .height(heightDp)
@@ -95,14 +95,14 @@ fun DynamicPreviewGrid(
.height(heightDp) .height(heightDp)
.clip(shape) .clip(shape)
.combinedClickable( .combinedClickable(
onLongClick = { currentOnLongClick(index) }, onLongClick = { currentOnLongClick(outerIndex * 3 + index) },
onClick = { currentOnClick(index) } onClick = { currentOnClick(outerIndex * 3 + index) }
) )
) )
if (preview.isVideo) { if (preview.isVideo) {
FastIconButton( FastIconButton(
onClick = { currentOnClick(index) }, onClick = { currentOnClick(outerIndex * 3 + index) },
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(CircleShape)
@@ -146,6 +146,10 @@ fun PreviewDynamicPhotoGrid() {
.padding(8.dp), .padding(8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
DynamicPreviewGrid(photos = mockPhotos.take(10).toImmutableList()) DynamicPreviewGrid(
withText = false,
withReply = false,
previews = mockPhotos.take(10).toImmutableList()
)
} }
} }
@@ -3,13 +3,11 @@ package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -18,90 +16,99 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp
import com.conena.nanokt.android.content.pxToDp
import dev.meloda.fast.domain.util.annotated import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.domain.util.orEmpty import dev.meloda.fast.domain.util.orEmpty
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
@Composable @Composable
fun Reply( fun Reply(
onClick: () -> Unit, onClick: () -> Unit,
bottomPadding: Dp,
shape: Shape, shape: Shape,
backgroundColor: Color, backgroundColor: Color,
innerBackgroundColor: Color, innerBackgroundColor: Color,
titleColor: Color,
textColor: Color,
title: String, title: String,
summary: AnnotatedString?, summary: AnnotatedString?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( var innerContainerHeight by remember {
mutableIntStateOf(0)
}
Row(
modifier = modifier modifier = modifier
.background( .background(
color = backgroundColor, color = backgroundColor,
shape = shape shape = shape
) )
.height(40.dp) .padding(4.dp)
.padding(
top = 4.dp,
start = 4.dp,
end = 4.dp,
bottom = bottomPadding
)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.fillMaxSize() .background(innerBackgroundColor),
.background(innerBackgroundColor) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(3.dp) .width(3.dp)
.fillMaxHeight() .height(innerContainerHeight.dp + 12.dp)
.background(MaterialTheme.colorScheme.onBackground) .background(MaterialTheme.colorScheme.primary)
) )
Spacer(modifier = Modifier.width(6.dp))
Column( Column(
modifier = Modifier.fillMaxHeight(), modifier = Modifier
verticalArrangement = Arrangement.Center .padding(vertical = 6.dp)
.padding(end = 9.dp)
.onGloballyPositioned { innerContainerHeight = it.size.height.pxToDp() }
) { ) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
color = titleColor
) )
AnimatedVisibility(summary != null) { AnimatedVisibility(summary != null) {
Text( Text(
text = summary.orEmpty(), text = summary.orEmpty(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
color = textColor
) )
} }
} }
} }
} }
}
@Composable @Composable
private fun ReplyBasePreview( private fun ReplyBasePreview(
backgroundColor: Color, backgroundColor: Color,
innerBackgroundColor: Color innerBackgroundColor: Color,
titleColor: Color,
textColor: Color
) { ) {
Reply( Reply(
modifier = Modifier.width(120.dp),
shape = RoundedCornerShape( shape = RoundedCornerShape(
topStart = 12.dp, topStart = 12.dp,
topEnd = 12.dp topEnd = 12.dp
@@ -111,24 +118,41 @@ private fun ReplyBasePreview(
summary = "2 photos".annotated(), summary = "2 photos".annotated(),
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
innerBackgroundColor = innerBackgroundColor, innerBackgroundColor = innerBackgroundColor,
bottomPadding = 0.dp titleColor = titleColor,
textColor = textColor,
) )
} }
@Preview @FastPreview
@Composable @Composable
private fun IncomingReplyPreview() { private fun IncomingReplyPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
ReplyBasePreview( ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp) innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp),
titleColor = MaterialTheme.colorScheme.primary,
textColor = MaterialTheme.colorScheme.onBackground
) )
} }
}
@Preview @FastPreview
@Composable @Composable
private fun OutgoingReplyPreview() { private fun OutgoingReplyPreview() {
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( ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.primaryContainer, backgroundColor = bg,
innerBackgroundColor = MaterialTheme.colorScheme.inversePrimary innerBackgroundColor = inner,
titleColor = title,
textColor = text
) )
} }
}
@@ -5,7 +5,6 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull import androidx.core.graphics.drawable.toBitmapOrNull
@@ -17,9 +16,11 @@ import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.UiImage 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.PhotoViewArguments
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.photoviewer.navigation.PhotoView import dev.meloda.fast.photoviewer.navigation.PhotoView
import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -28,9 +29,6 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.URLDecoder import java.net.URLDecoder
import java.util.UUID
import dev.meloda.fast.ui.R
interface PhotoViewViewModel { interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState> val screenState: StateFlow<PhotoViewScreenState>
@@ -99,9 +97,10 @@ class PhotoViewViewModelImpl(
type = "image/png" type = "image/png"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri) 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.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
chooserIntent chooserIntent
} }
@@ -186,6 +185,13 @@ class PhotoViewViewModelImpl(
private suspend fun downloadAndStoreImageToCache(url: String): File? = private suspend fun downloadAndStoreImageToCache(url: String): File? =
runCatching { 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) { withContext(Dispatchers.IO) {
screenState.setValue { old -> old.copy(isLoading = true) } screenState.setValue { old -> old.copy(isLoading = true) }
@@ -198,9 +204,6 @@ class PhotoViewViewModelImpl(
return@withContext null 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 { FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it) drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
} }
+4 -2
View File
@@ -3,12 +3,13 @@ agp = "9.0.0"
retrofit = "3.0.0" retrofit = "3.0.0"
eithernet = "2.0.0" eithernet = "2.0.0"
haze = "1.7.1" haze = "1.7.1"
kotlin = "2.3.0" kotlin = "2.3.10"
ksp = "2.3.4" ksp = "2.3.4"
moduleGraph = "2.9.0" moduleGraph = "2.9.0"
versions = "0.53.0" versions = "0.53.0"
stability-analyzer = "0.6.6"
compose-bom = "2026.01.00" compose-bom = "2026.01.01"
koin = "4.1.1" koin = "4.1.1"
accompanist = "0.37.3" accompanist = "0.37.3"
@@ -117,6 +118,7 @@ room = { id = "androidx.room", version.ref = "room" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
module-graph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" } module-graph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" }
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" } 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 #project plugins
fast-android-application = { id = "fast.android.application", version = "unspecified" } fast-android-application = { id = "fast.android.application", version = "unspecified" }