forked from melod1n/fast-messenger
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+13
-8
@@ -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
|
||||
|
||||
+41
-25
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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 = {}
|
||||
|
||||
+34
-18
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-13
@@ -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 = {}
|
||||
)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+29
-25
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+50
-19
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user