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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user