From c8bd485724304770b94b43432b7ec73864fd7c3a Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sat, 30 May 2026 11:32:40 +0300 Subject: [PATCH] feat: add custom segmented buttons and refactor conversation screen actions --- .gitignore | 1 + .../fast/ui/components/FastIconButton.kt | 22 ++-- .../fast/ui/components/SegmentedButtons.kt | 116 ++++++++++++++++++ .../dev/meloda/fast/ui/util/ImmutableList.kt | 7 ++ .../fast/convos/presentation/ConvosScreen.kt | 81 ++++++------ 5 files changed, 177 insertions(+), 50 deletions(-) create mode 100644 core/ui/src/main/kotlin/dev/meloda/fast/ui/components/SegmentedButtons.kt diff --git a/.gitignore b/.gitignore index bea964b8..1149f8d0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ local.properties .idea /.kotlin .hotswan/ +.java-version diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/FastIconButton.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/FastIconButton.kt index 5bb829f3..8dafc4f9 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/FastIconButton.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/FastIconButton.kt @@ -1,6 +1,7 @@ package dev.meloda.fast.ui.components import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Indication import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -20,6 +21,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @@ -30,27 +33,32 @@ fun FastIconButton( onLongClick: (() -> Unit)? = null, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + containerColor: Color = colors.containerColor(enabled), + contentColor: Color = colors.contentColor(enabled), + size: Dp = IconButtonTokens.StateLayerSize, + shape: Shape = IconButtonTokens.StateLayerShape, + alignment: Alignment = Alignment.Center, interactionSource: MutableInteractionSource? = null, + indication: Indication = ripple(), content: @Composable () -> Unit ) { Box( modifier = modifier .minimumInteractiveComponentSize() - .size(IconButtonTokens.StateLayerSize) - .clip(IconButtonTokens.StateLayerShape) - .background(color = colors.containerColor(enabled)) + .size(size) + .clip(shape) + .background(containerColor) .combinedClickable( onClick = onClick, onLongClick = onLongClick, enabled = enabled, interactionSource = interactionSource, - indication = ripple() + indication = indication ), - contentAlignment = Alignment.Center + contentAlignment = alignment ) { - val contentColor = colors.contentColor(enabled) - CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + CompositionLocalProvider(LocalContentColor provides contentColor) { content() } } } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/SegmentedButtons.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/SegmentedButtons.kt new file mode 100644 index 00000000..71cdaaa7 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/SegmentedButtons.kt @@ -0,0 +1,116 @@ +package dev.meloda.fast.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.meloda.fast.ui.util.ImmutableList + +data class SegmentedButtonItem( + val key: String, + val iconResId: Int +) + +@Composable +fun SegmentedButtonsRow( + items: ImmutableList, + onClick: (index: Int) -> Unit, + modifier: Modifier = Modifier, + containerShape: CornerBasedShape = RoundedCornerShape(24.dp), + containerColor: Color = MaterialTheme.colorScheme.background, + borderColor: Color = MaterialTheme.colorScheme.outlineVariant, + borderSize: Dp = 1.dp, + iconContainerWidth: Dp = 42.dp, + iconContainerHeight: Dp = 36.dp, + iconSize: Dp = 18.dp, + showDividers: Boolean = true +) { + SegmentedButtonsRow( + modifier = modifier.sizeIn(maxHeight = iconContainerHeight + borderSize), + items = items.mapIndexed { index, item -> + { + val first = index == 0 + val last = index == items.lastIndex + + if (showDividers && !first) { + VerticalDivider(modifier = Modifier.padding(vertical = iconContainerHeight / 4)) + } + + SegmentedButton( + onClick = { onClick(index) }, + iconResId = item.iconResId, + modifier = Modifier.size( + iconContainerWidth, + iconContainerHeight + ), + iconSize = iconSize, + shape = containerShape.copy( + topStart = if (!first) CornerSize(0.dp) else containerShape.topStart, + bottomStart = if (!first) CornerSize(0.dp) else containerShape.bottomStart, + topEnd = if (!last) CornerSize(0.dp) else containerShape.topEnd, + bottomEnd = if (!last) CornerSize(0.dp) else containerShape.bottomEnd + ) + ) + } + }, + containerShape = containerShape, + containerColor = containerColor, + borderColor = borderColor, + borderSize = borderSize + ) +} + +@Composable +fun SegmentedButtonsRow( + items: ImmutableList<@Composable () -> Unit>, + modifier: Modifier = Modifier, + containerShape: CornerBasedShape = RoundedCornerShape(24.dp), + containerColor: Color = MaterialTheme.colorScheme.background, + borderColor: Color = MaterialTheme.colorScheme.outlineVariant, + borderSize: Dp = 1.dp, +) { + Row( + modifier = modifier + .background(containerColor, containerShape) + .border(borderSize, borderColor, containerShape) + ) { + items.forEach { it.invoke() } + } +} + +@Composable +fun SegmentedButton( + onClick: () -> Unit, + iconResId: Int, + modifier: Modifier = Modifier, + iconSize: Dp = 18.dp, + shape: Shape = CircleShape +) { + FastIconButton( + onClick = onClick, + modifier = modifier, + shape = shape + ) { + Icon( + modifier = Modifier.size(iconSize), + painter = painterResource(iconResId), + contentDescription = null + ) + } +} 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 a1bca091..21759926 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 @@ -1,6 +1,7 @@ package dev.meloda.fast.ui.util import androidx.compose.runtime.Immutable +import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList @Immutable class ImmutableList(val values: List) : Collection { @@ -57,3 +58,9 @@ class ImmutableList(val values: List) : Collection { fun emptyImmutableList(): ImmutableList = ImmutableList(emptyList()) fun immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) + +inline fun buildImmutableList(builderAction: MutableList.() -> Unit): ImmutableList { + val mutableList = mutableListOf() + mutableList.apply(builderAction) + return mutableList.toImmutableList() +} 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 978ca6b0..8709ff79 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton @@ -53,7 +52,6 @@ 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 @@ -65,6 +63,8 @@ import dev.meloda.fast.model.BaseError import dev.meloda.fast.ui.R import dev.meloda.fast.ui.components.FullScreenContainedLoader import dev.meloda.fast.ui.components.NoItemsView +import dev.meloda.fast.ui.components.SegmentedButtonItem +import dev.meloda.fast.ui.components.SegmentedButtonsRow import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.model.vk.UiConvo @@ -73,10 +73,12 @@ import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalReselectedTab import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +import dev.meloda.fast.ui.util.buildImmutableList import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.isScrollingUp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlin.time.Duration.Companion.milliseconds @OptIn( ExperimentalMaterial3Api::class, @@ -126,13 +128,13 @@ fun ConvosScreen( LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } - .debounce(500L) + .debounce(500L.milliseconds) .collectLatest(setScrollIndex) } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemScrollOffset } - .debounce(500L) + .debounce(500L.milliseconds) .collectLatest(setScrollOffset) } @@ -201,54 +203,47 @@ fun ConvosScreen( } }, actions = { - if (!screenState.isArchive) { - IconButton(onClick = onArchiveActionClicked) { - Icon( - painter = painterResource(R.drawable.ic_archive_round_24), - contentDescription = null - ) + val dropDownItems: List<@Composable () -> Unit> = buildList {} + + val items = buildImmutableList { + if (!screenState.isArchive) { + add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24)) + } + + if (AppSettings.General.showManualRefreshOptions) { + add(SegmentedButtonItem("refresh", R.drawable.ic_refresh_round_24)) + } + + if (dropDownItems.isNotEmpty()) { + add(SegmentedButtonItem("more", R.drawable.ic_more_vert_round_24)) } } - val dropDownItems = mutableListOf<@Composable () -> Unit>() - if (AppSettings.General.showManualRefreshOptions) { - dropDownItems += { - DropdownMenuItem( - onClick = { - onRefresh() - dropDownMenuExpanded = false - }, - text = { - Text(text = stringResource(id = R.string.action_refresh)) - }, - leadingIcon = { - Icon( - painter = painterResource(R.drawable.ic_refresh_round_24), - contentDescription = null - ) - } - ) + SegmentedButtonsRow( + modifier = Modifier.padding(end = 8.dp), + items = items, + onClick = { index -> + when (items[index].key) { + "archive" -> onArchiveActionClicked() + "refresh" -> onRefresh() + "more" -> dropDownMenuExpanded = true + + else -> Unit + } } - } + ) if (dropDownItems.isNotEmpty()) { - IconButton(onClick = { dropDownMenuExpanded = true }) { - Icon( - painter = painterResource(R.drawable.ic_more_vert_round_24), - contentDescription = null - ) + DropdownMenu( + modifier = Modifier.defaultMinSize(minWidth = 140.dp), + expanded = dropDownMenuExpanded, + onDismissRequest = { dropDownMenuExpanded = false }, + offset = DpOffset(x = (-4).dp, y = (-60).dp) + ) { + dropDownItems.forEach { it.invoke() } } } - - DropdownMenu( - modifier = Modifier.defaultMinSize(minWidth = 140.dp), - expanded = dropDownMenuExpanded, - onDismissRequest = { dropDownMenuExpanded = false }, - offset = DpOffset(x = (-4).dp, y = (-60).dp) - ) { - dropDownItems.forEach { it.invoke() } - } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = toolbarContainerColor.copy(