feat: tab layout customization to metascreen

This commit is contained in:
tapframe 2026-04-03 20:34:57 +05:30
parent e83bdc2d53
commit 5e9b7c07a0
14 changed files with 444 additions and 159 deletions

View file

@ -50,6 +50,7 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
@ -318,6 +319,8 @@ fun NuvioInfoBadge(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View file

@ -62,13 +62,15 @@ fun <T> NuvioShelfSection(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioShelfSectionHeader(
title = title,
modifier = Modifier.padding(horizontal = headerHorizontalPadding),
showAccent = showHeaderAccent,
onViewAllClick = onViewAllClick,
viewAllPillSize = viewAllPillSize,
)
if (title.isNotBlank()) {
NuvioShelfSectionHeader(
title = title,
modifier = Modifier.padding(horizontal = headerHorizontalPadding),
showAccent = showHeaderAccent,
onViewAllClick = onViewAllClick,
viewAllPillSize = viewAllPillSize,
)
}
LazyRow(
contentPadding = rowContentPadding,
horizontalArrangement = Arrangement.spacedBy(itemSpacing),

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -368,9 +369,10 @@ private fun InstalledAddonCard(
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(modifier = Modifier.height(18.dp))
Row(
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(
text = when {

View file

@ -1,11 +1,13 @@
package com.nuvio.app.features.details
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -46,6 +48,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
@ -810,106 +813,208 @@ private fun ConfiguredMetaSections(
onCastClick: ((MetaPerson) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
) {
settings.items
.filter { it.enabled }
.forEach { section ->
when (section.key) {
MetaScreenSectionKey.ACTIONS -> {
DetailActionButtons(
playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save",
isSaved = isSaved,
isTablet = isTablet,
onPlayClick = onPrimaryPlayClick,
onSaveClick = onSaveClick,
val enabledItems = settings.items.filter { it.enabled }
// Helper to check if a section actually has content to show
val sectionHasContent: (MetaScreenSectionKey) -> Boolean = { key ->
when (key) {
MetaScreenSectionKey.ACTIONS -> true
MetaScreenSectionKey.OVERVIEW -> true
MetaScreenSectionKey.PRODUCTION -> hasProductionSection
MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty()
MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())
MetaScreenSectionKey.TRAILERS -> hasTrailersSection
MetaScreenSectionKey.EPISODES -> hasEpisodes
MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection
MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection
MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection
}
}
@Composable
fun RenderSection(key: MetaScreenSectionKey, showHeader: Boolean = true) {
when (key) {
MetaScreenSectionKey.ACTIONS -> {
DetailActionButtons(
playLabel = playButtonLabel,
saveLabel = if (isSaved) "Saved" else "Save",
isSaved = isSaved,
isTablet = isTablet,
onPlayClick = onPrimaryPlayClick,
onSaveClick = onSaveClick,
)
}
MetaScreenSectionKey.OVERVIEW -> {
DetailMetaInfo(meta = meta)
}
MetaScreenSectionKey.PRODUCTION -> {
if (hasProductionSection) {
DetailProductionSection(meta = meta, showHeader = showHeader, onCompanyClick = onCompanyClick)
}
}
MetaScreenSectionKey.CAST -> {
DetailCastSection(cast = meta.cast, showHeader = showHeader, onCastClick = onCastClick)
}
MetaScreenSectionKey.COMMENTS -> {
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {
DetailCommentsSection(
comments = comments,
isLoading = isCommentsLoading,
isLoadingMore = isCommentsLoadingMore,
canLoadMore = commentsCurrentPage < commentsPageCount,
error = commentsError,
onRetry = onRetryComments,
onLoadMore = onLoadMoreComments,
onCommentClick = onCommentClick,
showHeader = showHeader,
)
}
MetaScreenSectionKey.OVERVIEW -> {
DetailMetaInfo(meta = meta)
}
MetaScreenSectionKey.TRAILERS -> {
if (hasTrailersSection) {
DetailTrailersSection(trailers = meta.trailers, onTrailerClick = onTrailerClick, showHeader = showHeader)
}
MetaScreenSectionKey.PRODUCTION -> {
if (hasProductionSection) {
DetailProductionSection(
meta = meta,
onCompanyClick = onCompanyClick,
)
}
}
MetaScreenSectionKey.CAST -> {
DetailCastSection(
cast = meta.cast,
onCastClick = onCastClick,
}
MetaScreenSectionKey.EPISODES -> {
if (hasEpisodes) {
DetailSeriesContent(
meta = meta,
showHeader = showHeader,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
)
}
MetaScreenSectionKey.COMMENTS -> {
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {
DetailCommentsSection(
comments = comments,
isLoading = isCommentsLoading,
isLoadingMore = isCommentsLoadingMore,
canLoadMore = commentsCurrentPage < commentsPageCount,
error = commentsError,
onRetry = onRetryComments,
onLoadMore = onLoadMoreComments,
onCommentClick = onCommentClick,
)
}
}
MetaScreenSectionKey.DETAILS -> {
if (hasAdditionalInfoSection) {
DetailAdditionalInfoSection(meta = meta, showHeader = showHeader)
}
MetaScreenSectionKey.TRAILERS -> {
if (hasTrailersSection) {
DetailTrailersSection(
trailers = meta.trailers,
onTrailerClick = onTrailerClick,
)
}
}
MetaScreenSectionKey.COLLECTION -> {
if (!hasEpisodes && hasCollectionSection) {
DetailPosterRailSection(
title = meta.collectionName.orEmpty(),
items = meta.collectionItems,
watchedKeys = watchedKeys,
showHeader = showHeader,
onPosterClick = onOpenMeta,
)
}
MetaScreenSectionKey.EPISODES -> {
if (hasEpisodes) {
DetailSeriesContent(
meta = meta,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
)
}
}
MetaScreenSectionKey.DETAILS -> {
if (hasAdditionalInfoSection) {
DetailAdditionalInfoSection(meta = meta)
}
}
MetaScreenSectionKey.COLLECTION -> {
if (!hasEpisodes && hasCollectionSection) {
DetailPosterRailSection(
title = meta.collectionName.orEmpty(),
items = meta.collectionItems,
watchedKeys = watchedKeys,
onPosterClick = onOpenMeta,
)
}
}
MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) {
DetailPosterRailSection(
title = "More Like This",
items = meta.moreLikeThis,
watchedKeys = watchedKeys,
onPosterClick = onOpenMeta,
)
}
}
MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) {
DetailPosterRailSection(
title = "More Like This",
items = meta.moreLikeThis,
watchedKeys = watchedKeys,
showHeader = showHeader,
onPosterClick = onOpenMeta,
)
}
}
}
}
if (!settings.tabLayout) {
// Standard mode: render sections individually in order
enabledItems.forEach { section -> RenderSection(section.key) }
} else {
// Tab layout mode: group sections by tabGroup, render grouped ones as tabs
val processedGroups = mutableSetOf<Int>()
enabledItems.forEach { section ->
val groupId = section.tabGroup
if (groupId == null) {
// Standalone section
RenderSection(section.key)
} else if (groupId !in processedGroups) {
// First encounter of this group — render the whole tabbed group
processedGroups.add(groupId)
val groupMembers = enabledItems
.filter { it.tabGroup == groupId && sectionHasContent(it.key) }
if (groupMembers.isEmpty()) return@forEach
if (groupMembers.size == 1) {
// Only one member with content — render standalone
RenderSection(groupMembers.first().key)
} else {
TabbedSectionGroup(
tabs = groupMembers.map { it.key to it.title },
) { activeKey ->
RenderSection(activeKey, showHeader = false)
}
}
}
// else: already processed as part of group, skip
}
}
}
@Composable
private fun TabbedSectionGroup(
tabs: List<Pair<MetaScreenSectionKey, String>>,
content: @Composable (MetaScreenSectionKey) -> Unit,
) {
if (tabs.isEmpty()) return
var selectedIndex by remember { mutableIntStateOf(0) }
val clampedIndex = selectedIndex.coerceIn(0, tabs.lastIndex)
if (clampedIndex != selectedIndex) selectedIndex = clampedIndex
val headerColor = MaterialTheme.colorScheme.onBackground
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// Tab row using the same style as DetailSectionTitle
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val titleSize = if (maxWidth >= 720.dp) 22.sp else 20.sp
val headerStyle = MaterialTheme.typography.titleLarge.copy(
fontSize = titleSize,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
tabs.forEachIndexed { index, (_, title) ->
if (index > 0) {
Text(
text = "|",
style = headerStyle,
color = headerColor.copy(alpha = 0.45f),
modifier = Modifier.padding(horizontal = 10.dp),
)
}
Text(
text = title,
style = headerStyle,
color = if (index == selectedIndex) {
headerColor
} else {
headerColor.copy(alpha = 0.55f)
},
maxLines = 1,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { selectedIndex = index },
)
}
}
}
// Content with crossfade
Crossfade(
targetState = tabs[selectedIndex].first,
animationSpec = tween(durationMillis = 200),
label = "tabbedSectionCrossfade",
) { activeKey ->
content(activeKey)
}
}
}
private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@ -19,6 +20,11 @@ enum class MetaScreenSectionKey {
DETAILS,
COLLECTION,
MORE_LIKE_THIS,
;
val canBeTabbed: Boolean
get() = this != ACTIONS && this != OVERVIEW
}
data class MetaScreenSectionItem(
@ -27,11 +33,13 @@ data class MetaScreenSectionItem(
val description: String,
val enabled: Boolean,
val order: Int,
val tabGroup: Int? = null,
)
data class MetaScreenSettingsUiState(
val items: List<MetaScreenSectionItem> = emptyList(),
val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false,
)
@Serializable
@ -39,12 +47,15 @@ private data class StoredMetaScreenSectionPreference(
val key: String,
val enabled: Boolean = true,
val order: Int = 0,
val tabGroup: Int? = null,
)
@Serializable
private data class StoredMetaScreenSettingsPayload(
val items: List<StoredMetaScreenSectionPreference> = emptyList(),
val cinematicBackground: Boolean = false,
@SerialName("tvStyleLayout")
val tabLayout: Boolean = false,
)
private data class MetaScreenSectionDefinition(
@ -118,6 +129,7 @@ object MetaScreenSettingsRepository {
private var hasLoaded = false
private var preferences: MutableMap<MetaScreenSectionKey, StoredMetaScreenSectionPreference> = mutableMapOf()
private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false
fun ensureLoaded() {
if (hasLoaded) return
@ -130,6 +142,7 @@ object MetaScreenSettingsRepository {
}.getOrNull()
if (parsed != null) {
cinematicBackground = parsed.cinematicBackground
tabLayout = parsed.tabLayout
preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item
@ -146,6 +159,7 @@ object MetaScreenSettingsRepository {
hasLoaded = false
preferences.clear()
cinematicBackground = false
tabLayout = false
_uiState.value = MetaScreenSettingsUiState()
ensureLoaded()
}
@ -157,10 +171,31 @@ object MetaScreenSettingsRepository {
persist()
}
fun setTabLayout(enabled: Boolean) {
ensureLoaded()
tabLayout = enabled
publish()
persist()
}
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded()
if (!key.canBeTabbed) return
if (groupId != null) {
// Enforce max 3 sections per group
val currentGroupCount = preferences.count { it.value.tabGroup == groupId && it.key != key }
if (currentGroupCount >= 3) return
}
updatePreference(key) { preference ->
preference.copy(tabGroup = groupId)
}
}
fun clearLocalState() {
hasLoaded = false
preferences.clear()
cinematicBackground = false
tabLayout = false
_uiState.value = MetaScreenSettingsUiState()
}
@ -174,6 +209,7 @@ object MetaScreenSettingsRepository {
ensureLoaded()
preferences.clear()
cinematicBackground = false
tabLayout = false
normalizePreferences()
publish()
persist()
@ -216,6 +252,7 @@ object MetaScreenSettingsRepository {
key = definition.key.name,
enabled = stored?.enabled ?: true,
order = index,
tabGroup = stored?.tabGroup,
)
}
preferences = normalized
@ -233,9 +270,11 @@ object MetaScreenSettingsRepository {
description = definition.description,
enabled = preference?.enabled ?: true,
order = preference?.order ?: 0,
tabGroup = preference?.tabGroup,
)
},
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
)
}
@ -245,6 +284,7 @@ object MetaScreenSettingsRepository {
StoredMetaScreenSettingsPayload(
items = preferences.values.sortedBy { it.order },
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
),
),
)

View file

@ -23,6 +23,7 @@ import com.nuvio.app.features.details.MetaDetails
fun DetailAdditionalInfoSection(
meta: MetaDetails,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
) {
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
val title = if (isSeriesLike) "Show Details" else "Movie Details"
@ -39,6 +40,7 @@ fun DetailAdditionalInfoSection(
DetailSection(
title = title,
modifier = modifier,
showHeader = showHeader,
) {
rows.forEachIndexed { index, (label, value) ->
DetailInfoRow(

View file

@ -32,6 +32,7 @@ import com.nuvio.app.features.details.MetaPerson
fun DetailCastSection(
cast: List<MetaPerson>,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
onCastClick: ((MetaPerson) -> Unit)? = null,
) {
if (cast.isEmpty()) return
@ -39,6 +40,7 @@ fun DetailCastSection(
DetailSection(
title = "Cast",
modifier = modifier,
showHeader = showHeader,
) {
BoxWithConstraints {
val sizing = castSectionSizing(maxWidth.value)

View file

@ -51,6 +51,7 @@ fun DetailCommentsSection(
onLoadMore: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
) {
val listState = rememberLazyListState()
@ -68,8 +69,10 @@ fun DetailCommentsSection(
}
Column(modifier = modifier.fillMaxWidth()) {
CommentsHeader()
Spacer(modifier = Modifier.height(12.dp))
if (showHeader) {
CommentsHeader()
Spacer(modifier = Modifier.height(12.dp))
}
when {
isLoading -> {

View file

@ -17,6 +17,7 @@ fun DetailPosterRailSection(
items: List<MetaPreview>,
watchedKeys: Set<String>,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
headerHorizontalPadding: Dp = 0.dp,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -24,7 +25,7 @@ fun DetailPosterRailSection(
if (items.isEmpty()) return
NuvioShelfSection(
title = title,
title = if (showHeader) title else "",
entries = items,
modifier = modifier,
headerHorizontalPadding = headerHorizontalPadding,

View file

@ -31,6 +31,7 @@ import com.nuvio.app.features.details.MetaDetails
fun DetailProductionSection(
meta: MetaDetails,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
) {
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
@ -55,6 +56,7 @@ fun DetailProductionSection(
DetailSection(
title = if (isSeriesLike) "Network" else "Production",
modifier = modifier,
showHeader = showHeader,
) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val chipHeight = when {

View file

@ -16,13 +16,16 @@ import androidx.compose.ui.unit.sp
fun DetailSection(
title: String,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
DetailSectionTitle(title = title)
if (showHeader) {
DetailSectionTitle(title = title)
}
content()
}
}

View file

@ -69,6 +69,7 @@ private val log = Logger.withTag("SeriesContent")
fun DetailSeriesContent(
meta: MetaDetails,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
watchedKeys: Set<String> = emptySet(),
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
@ -81,6 +82,7 @@ fun DetailSeriesContent(
DetailSection(
title = "Episodes",
modifier = modifier,
showHeader = showHeader,
) {
Text(
text = when {
@ -120,6 +122,7 @@ fun DetailSeriesContent(
DetailSection(
title = "Episodes",
modifier = modifier,
showHeader = showHeader,
) {
Text(
text = "This addon returned videos for the series, but none included season or episode numbers.",

View file

@ -44,6 +44,7 @@ fun DetailTrailersSection(
trailers: List<MetaTrailer>,
onTrailerClick: (MetaTrailer) -> Unit,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
) {
if (trailers.isEmpty()) return
@ -79,10 +80,12 @@ fun DetailTrailersSection(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DetailSectionTitle(
title = "Trailers",
fullWidth = false,
)
if (showHeader) {
DetailSectionTitle(
title = "Trailers",
fullWidth = false,
)
}
Box {
Surface(

View file

@ -1,18 +1,30 @@
package com.nuvio.app.features.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -25,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
@ -55,6 +68,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
isTablet = isTablet,
onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = "Tab Layout",
description = "Group sections into tabs like the TV app. Assign up to 3 sections per tab group.",
checked = uiState.tabLayout,
isTablet = isTablet,
onCheckedChange = { MetaScreenSettingsRepository.setTabLayout(it) },
)
}
}
}
@ -73,6 +94,7 @@ internal fun LazyListScope.metaScreenSettingsContent(
MetaSectionReorderableList(
items = uiState.items,
isTablet = isTablet,
tabLayout = uiState.tabLayout,
)
}
}
@ -83,6 +105,7 @@ internal fun LazyListScope.metaScreenSettingsContent(
private fun MetaSectionReorderableList(
items: List<MetaScreenSectionItem>,
isTablet: Boolean,
tabLayout: Boolean,
) {
val hapticFeedback = LocalHapticFeedback.current
val lazyListState = rememberLazyListState()
@ -93,6 +116,13 @@ private fun MetaSectionReorderableList(
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
// Count members per group for enforcing max 3
val groupCounts: Map<Int, Int> = if (tabLayout) {
items.filter { it.tabGroup != null }.groupBy { it.tabGroup!! }.mapValues { it.value.size }
} else {
emptyMap()
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
@ -111,7 +141,10 @@ private fun MetaSectionReorderableList(
MetaSectionRow(
item = item,
isTablet = isTablet,
tabLayout = tabLayout,
groupCounts = groupCounts,
onEnabledChange = { MetaScreenSettingsRepository.setEnabled(item.key, it) },
onTabGroupChange = { MetaScreenSettingsRepository.setTabGroup(item.key, it) },
dragHandleScope = this@ReorderableItem,
)
}
@ -121,77 +154,158 @@ private fun MetaSectionReorderableList(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun MetaSectionRow(
item: MetaScreenSectionItem,
isTablet: Boolean,
tabLayout: Boolean,
groupCounts: Map<Int, Int>,
onEnabledChange: (Boolean) -> Unit,
onTabGroupChange: (Int?) -> Unit,
dragHandleScope: ReorderableCollectionItemScope,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp
val hapticFeedback = LocalHapticFeedback.current
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (item.enabled) "Visible" else "Hidden",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = item.enabled,
onCheckedChange = onEnabledChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
IconButton(
modifier = with(dragHandleScope) {
Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = {},
) {
Icon(
Icons.Rounded.Menu,
contentDescription = "Reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
Text(
text = item.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (item.enabled) "Visible" else "Hidden",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (tabLayout && item.tabGroup != null) {
Box(
modifier = Modifier
.size(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)),
)
Text(
text = "Tab Group ${item.tabGroup}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
}
Switch(
checked = item.enabled,
onCheckedChange = onEnabledChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
IconButton(
modifier = with(dragHandleScope) {
Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
)
},
onClick = {},
) {
Icon(
Icons.Rounded.Menu,
contentDescription = "Reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
AnimatedVisibility(
visible = tabLayout && item.enabled && item.key.canBeTabbed,
enter = expandVertically(),
exit = shrinkVertically(),
) {
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TabGroupChip(
label = "None",
selected = item.tabGroup == null,
onClick = { onTabGroupChange(null) },
)
for (groupId in 1..3) {
val currentCount = groupCounts[groupId] ?: 0
val isSelected = item.tabGroup == groupId
val isFull = currentCount >= 3 && !isSelected
TabGroupChip(
label = "Group $groupId",
selected = isSelected,
enabled = !isFull,
onClick = { onTabGroupChange(groupId) },
)
}
}
}
}
}
@Composable
private fun TabGroupChip(
label: String,
selected: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
FilterChip(
selected = selected,
onClick = onClick,
enabled = enabled,
label = {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}