diff --git a/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt b/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt index 54e294d9..35ebfb66 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt @@ -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() { diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt index 686a9496..232ca072 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt @@ -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, Int?>?>(null) + mutableStateOf, 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 } ) } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt index 1ad0d7fc..a59a1b75 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin { 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() configureAndroidCompose(extension) diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index 8004e599..b409db82 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -10,6 +10,7 @@ class AndroidLibraryComposeConventionPlugin : Plugin { 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() extension.androidResources.enable = false diff --git a/build.gradle.kts b/build.gradle.kts index 7489e407..1ac9c437 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } diff --git a/core/common/src/main/kotlin/dev/meloda/fast/common/util/TimeUtils.kt b/core/common/src/main/kotlin/dev/meloda/fast/common/util/TimeUtils.kt index 12200c41..53d9ea4f 100644 --- a/core/common/src/main/kotlin/dev/meloda/fast/common/util/TimeUtils.kt +++ b/core/common/src/main/kotlin/dev/meloda/fast/common/util/TimeUtils.kt @@ -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() } } } diff --git a/core/common/src/main/kotlin/dev/meloda/fast/common/util/Utils.kt b/core/common/src/main/kotlin/dev/meloda/fast/common/util/Utils.kt index 93d7caac..053837df 100644 --- a/core/common/src/main/kotlin/dev/meloda/fast/common/util/Utils.kt +++ b/core/common/src/main/kotlin/dev/meloda/fast/common/util/Utils.kt @@ -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) } +} diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetCurrentAccountUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetCurrentAccountUseCase.kt index b083992b..f50ab8bb 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetCurrentAccountUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetCurrentAccountUseCase.kt @@ -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) } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/util/ConvoUiMapper.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/util/ConvoUiMapper.kt index 4b77686e..4423f036 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/util/ConvoUiMapper.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/util/ConvoUiMapper.kt @@ -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), diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachment.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachment.kt index ce176c78..c7c687c4 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachment.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkAttachment.kt @@ -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 } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/common/FastPreview.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/common/FastPreview.kt new file mode 100644 index 00000000..9fd35765 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/common/FastPreview.kt @@ -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 diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt index 5e0b6284..b8acb12c 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt @@ -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 diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt index 3c7a0d84..d0c370a7 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt @@ -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" + ) + } + } } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt index 3b7c3143..a3f5f5b5 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/extensions/SharedViewModel.kt @@ -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 NavBackStackEntry.sharedViewModel( navController: NavController, diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt index 8dcd6cc0..a1bca091 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt @@ -9,10 +9,6 @@ class ImmutableList(val values: List) : Collection { operator fun get(index: Int): T = values[index] - inline fun forEach(action: (T) -> Unit) { - for (element in values) action(element) - } - inline fun map(transform: (T) -> R): ImmutableList { return values.map(transform).toImmutableList() } @@ -49,6 +45,8 @@ class ImmutableList(val values: List) : Collection { if (elements.isNotEmpty()) copyOf(elements.asList()) else empty() fun of(element: T) = ImmutableList(listOf(element)) + + fun ImmutableList?.orEmpty(): ImmutableList = this ?: emptyImmutableList() } override fun iterator(): Iterator = values.listIterator() @@ -59,5 +57,3 @@ class ImmutableList(val values: List) : Collection { fun emptyImmutableList(): ImmutableList = ImmutableList(emptyList()) fun immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) - -fun ImmutableList?.orEmpty(): ImmutableList = this ?: emptyImmutableList() diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index 4e39bc12..3999ba49 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -232,8 +232,10 @@ М Н Д + С Сейчас Вы действительно хотите создать чат «%s»? Вы действительно хотите создать чат «%s» только с собой? + М diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 30f7c9df..6ff96f7b 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + Fast Fast Messenger @@ -297,6 +297,8 @@ M W D + M + S Now Are you sure you want to create chat «%s»? diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt index 95e6ba2f..3e30bee8 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt @@ -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" + ) ) - ) + } } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt index 9bb1a367..ae2df718 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt @@ -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 diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt index b9374faa..9f1342eb 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt @@ -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 + ) + } } diff --git a/feature/convos/src/main/kotlin/dev/meloda/fast/convos/presentation/ConvosScreen.kt b/feature/convos/src/main/kotlin/dev/meloda/fast/convos/presentation/ConvosScreen.kt index 5cfe39a8..978ca6b0 100644 --- a/feature/convos/src/main/kotlin/dev/meloda/fast/convos/presentation/ConvosScreen.kt +++ b/feature/convos/src/main/kotlin/dev/meloda/fast/convos/presentation/ConvosScreen.kt @@ -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 diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt index 5e8cf9e3..1e7b24d9 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt @@ -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 { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt index 27ffead1..1167874c 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryDialogs.kt @@ -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 = {} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index 9a6123ae..574a31a7 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -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 ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt index bcd760ee..cdc47fef 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt @@ -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, 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 = {} ) diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt index 028eec90..b4c7c01b 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/AudioMessage.kt @@ -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: ImmutableList, isPlaying: Boolean, onPlayClick: () -> Unit, modifier: Modifier = Modifier, diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt index 9dd16509..5018724a 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt @@ -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, - onClick: (index: Int) -> Unit = {}, - onLongClick: (index: Int) -> Unit = {} -) { - DynamicPreviewGrid( - modifier = modifier, - photos = photos, - onClick = onClick, - onLongClick = onLongClick - ) -} -@SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun DynamicPreviewGrid( - photos: ImmutableList, + withText: Boolean, + withReply: Boolean, + previews: ImmutableList, 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() + ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt index b6e8be9d..c92a05ec 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Reply.kt @@ -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 + ) + } } diff --git a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt index dd97ab40..8787035d 100644 --- a/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt +++ b/feature/photoviewer/src/main/kotlin/dev/meloda/fast/photoviewer/PhotoViewViewModel.kt @@ -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 @@ -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) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa86a2cd..49ba5235 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }