From dbf13c467fd053e0433919e3f1e3532a78db5f81 Mon Sep 17 00:00:00 2001 From: Lasta apps Date: Mon, 15 Jul 2024 00:30:10 +0300 Subject: [PATCH] feat: Added carousel view --- .../api/main/data/KocourkovRepoImpl.kt | 13 +- .../settings/domain/model/DishListMode.kt | 3 +- .../today/ui/screen/DishListScreen.kt | 26 +- .../features/today/ui/widget/DishImage.kt | 39 ++- .../today/ui/widget/DishListViewModeSwitch.kt | 1 + .../today/ui/widget/TodayDishCarousel.kt | 301 ++++++++++++++++++ .../today/ui/widget/TodayDishHorizontal.kt | 29 +- app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 385 insertions(+), 29 deletions(-) create mode 100644 app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishCarousel.kt diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/KocourkovRepoImpl.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/KocourkovRepoImpl.kt index d99bf6df..1c6c0cd7 100644 --- a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/KocourkovRepoImpl.kt +++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/KocourkovRepoImpl.kt @@ -210,12 +210,13 @@ internal object TodayKocourkovDishRepoImpl : TodayDishRepo { override suspend fun sync(params: TodayRepoParams, isForced: Boolean): SyncOutcome { delay(Random.nextInt(100..1000).milliseconds) - return if (Random.nextInt(0..3) != 0) { - localNotifier.value += 1 - SyncResult.Updated.right() - } else { - SyncResult.Skipped.right() - } + return SyncResult.Skipped.right() +// return if (Random.nextInt(0..3) != 0) { +// localNotifier.value += 1 +// SyncResult.Updated.right() +// } else { +// SyncResult.Skipped.right() +// } } } diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/settings/domain/model/DishListMode.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/settings/domain/model/DishListMode.kt index 48489020..91ce768d 100644 --- a/app/src/main/kotlin/cz/lastaapps/menza/features/settings/domain/model/DishListMode.kt +++ b/app/src/main/kotlin/cz/lastaapps/menza/features/settings/domain/model/DishListMode.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved + * Copyright 2024, Petr Laštovička as Lasta apps, All rights reserved * * This file is part of Menza. * @@ -23,5 +23,6 @@ enum class DishListMode(val id: Int) { COMPACT(0), GRID(1), HORIZONTAL(2), + CAROUSEL(3), ; } diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/screen/DishListScreen.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/screen/DishListScreen.kt index c89a22a6..022364fa 100644 --- a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/screen/DishListScreen.kt +++ b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/screen/DishListScreen.kt @@ -52,6 +52,7 @@ import cz.lastaapps.api.core.domain.model.Dish import cz.lastaapps.core.ui.vm.HandleAppear import cz.lastaapps.menza.R import cz.lastaapps.menza.features.settings.domain.model.DishListMode +import cz.lastaapps.menza.features.settings.domain.model.DishListMode.CAROUSEL import cz.lastaapps.menza.features.settings.domain.model.DishListMode.COMPACT import cz.lastaapps.menza.features.settings.domain.model.DishListMode.GRID import cz.lastaapps.menza.features.settings.domain.model.DishListMode.HORIZONTAL @@ -60,6 +61,7 @@ import cz.lastaapps.menza.features.today.ui.vm.DishListViewModel import cz.lastaapps.menza.features.today.ui.widget.DishListViewModeSwitch import cz.lastaapps.menza.features.today.ui.widget.Experimental import cz.lastaapps.menza.features.today.ui.widget.ImageSizeSetting +import cz.lastaapps.menza.features.today.ui.widget.TodayDishCarousel import cz.lastaapps.menza.features.today.ui.widget.TodayDishGrid import cz.lastaapps.menza.features.today.ui.widget.TodayDishHorizontal import cz.lastaapps.menza.features.today.ui.widget.TodayDishList @@ -140,7 +142,7 @@ private fun DishListContent( } val footerFabPadding: @Composable () -> Unit = { state.selectedMenza?.getOrNull()?.videoLinks?.firstOrNull()?.let { - Spacer(Modifier.height(96.dp)) + Spacer(Modifier.height(96.dp + Padding.MidSmall)) } } @@ -233,6 +235,25 @@ private fun DishListContent( scroll = scrollStates.horizontal, ) + CAROUSEL -> TodayDishCarousel( + isLoading = state.isLoading, + onRefresh = onRefresh, + data = state.items, + onNoItems = onNoItems, + onDishSelected = onDishSelected, + userSettings = userSettings, + isOnMetered = state.isOnMetered, + header = header, + footer = { + Column { + gridSwitch() + footerFabPadding() + } + }, + modifier = modifier.fillMaxSize(), + scroll = scrollStates.carousel, + ) + null -> {} } } @@ -267,6 +288,7 @@ internal data class ScrollStates( val list: LazyListState = LazyListState(), val grid: LazyStaggeredGridState = LazyStaggeredGridState(), val horizontal: LazyListState = LazyListState(), + val carousel: LazyListState = LazyListState(), ) { companion object { val Saver: Saver = listSaver( @@ -275,6 +297,7 @@ internal data class ScrollStates( with(LazyListState.Saver) { save(it.list) }, with(LazyStaggeredGridState.Saver) { save(it.grid) }, with(LazyListState.Saver) { save(it.horizontal) }, + with(LazyListState.Saver) { save(it.carousel) }, ) }, restore = { list -> @@ -284,6 +307,7 @@ internal data class ScrollStates( list[0]?.let { llsSaver.restore(it) }!!, list[1]?.let { LazyStaggeredGridState.Saver.restore(it) }!!, list[2]?.let { llsSaver.restore(it) }!!, + list[3]?.let { llsSaver.restore(it) }!!, ) }, ) diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishImage.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishImage.kt index 8c1143a9..a2f08fad 100644 --- a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishImage.kt +++ b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishImage.kt @@ -23,7 +23,7 @@ import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -55,6 +55,7 @@ import coil3.Extras import coil3.compose.SubcomposeAsyncImage import coil3.request.CachePolicy import coil3.request.ImageRequest.Builder +import cz.lastaapps.api.core.domain.model.Dish import cz.lastaapps.menza.R.string import cz.lastaapps.menza.ui.components.placeholders.PlaceholderHighlight import cz.lastaapps.menza.ui.components.placeholders.fade @@ -63,16 +64,42 @@ import cz.lastaapps.menza.ui.util.PreviewWrapper import kotlin.math.absoluteValue import kotlin.random.Random +internal fun loadImmediately(downloadOnMetered: Boolean, isOnMetered: Boolean) = + downloadOnMetered || !isOnMetered + +@Composable +internal fun DishImageOrSupplement( + dish: Dish, + loadImmediately: Boolean, + modifier: Modifier = Modifier, + ratio: Float? = DishImageTokens.ASPECT_RATIO, +) { + val imageModifier = (ratio?.let { modifier.aspectRatio(it) } ?: modifier).fillMaxSize() + dish.photoLink?.let { + DishImage( + it, + loadImmediately = loadImmediately, + modifier = imageModifier, + ) + } ?: run { + DishImageSupplement( + dish.name.hashCode(), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = imageModifier, + ) + } +} @Composable internal fun DishImageRatio( photoLink: String, loadImmediately: Boolean, modifier: Modifier = Modifier, + ratio: Float = DishImageTokens.ASPECT_RATIO, ) { val imageModifier = modifier - .fillMaxWidth() - .aspectRatio(DishImageTokens.ASPECT_RATIO) + .aspectRatio(ratio) + .fillMaxSize() DishImage( photoLink = photoLink, @@ -155,7 +182,7 @@ internal fun DishImageSupplement( ) { Surface( shape = MaterialTheme.shapes.medium, - modifier = modifier.aspectRatio(DishImageTokens.ASPECT_RATIO), + modifier = modifier, color = color, ) { val icon = @@ -194,7 +221,9 @@ private fun DishImageSupplementPreview() = PreviewWrapper() { DishImageSupplement( remember { Random.nextInt() }, color = it, - modifier = Modifier.padding(12.dp), + modifier = Modifier + .padding(12.dp) + .aspectRatio(DishImageTokens.ASPECT_RATIO), ) } } diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishListViewModeSwitch.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishListViewModeSwitch.kt index f8ef0ac5..9fcb6517 100644 --- a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishListViewModeSwitch.kt +++ b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/DishListViewModeSwitch.kt @@ -44,6 +44,7 @@ internal fun DishListViewModeSwitch( DishListMode.COMPACT to R.string.today_list_mode_compact, DishListMode.GRID to R.string.today_list_mode_grid, DishListMode.HORIZONTAL to R.string.today_list_mode_horizontal, + DishListMode.CAROUSEL to R.string.today_list_mode_carousel, ) } SingleChoiceSegmentedButtonRow( diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishCarousel.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishCarousel.kt new file mode 100644 index 00000000..bc94c21e --- /dev/null +++ b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishCarousel.kt @@ -0,0 +1,301 @@ +/* + * Copyright 2024, Petr Laštovička as Lasta apps, All rights reserved + * + * This file is part of Menza. + * + * Menza is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Menza is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Menza. If not, see . + */ + +package cz.lastaapps.menza.features.today.ui.widget + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import cz.lastaapps.api.core.domain.model.Dish +import cz.lastaapps.api.core.domain.model.DishCategory +import cz.lastaapps.menza.features.today.domain.model.TodayUserSettings +import cz.lastaapps.menza.ui.components.NoItems +import cz.lastaapps.menza.ui.components.PullToRefreshWrapper +import cz.lastaapps.menza.ui.theme.Padding +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun TodayDishCarousel( + isLoading: Boolean, + onRefresh: () -> Unit, + data: ImmutableList, + onNoItems: () -> Unit, + onDishSelected: (Dish) -> Unit, + userSettings: TodayUserSettings, + isOnMetered: Boolean, + header: @Composable (Modifier) -> Unit, + footer: @Composable (Modifier) -> Unit, + scroll: LazyListState, + modifier: Modifier = Modifier, +) { + PullToRefreshWrapper( + isRefreshing = isLoading, + onRefresh = onRefresh, + modifier = modifier.fillMaxWidth(), + ) { + Surface(shape = MaterialTheme.shapes.large) { + DishContent( + data = data, + onDishSelected = onDishSelected, + onNoItems = onNoItems, + appSettings = userSettings, + isOnMetered = isOnMetered, + scroll = scroll, + header = header, + footer = footer, + modifier = Modifier + .padding(top = Padding.Smaller) // so text is not cut off + .fillMaxSize(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DishContent( + data: ImmutableList, + onDishSelected: (Dish) -> Unit, + onNoItems: () -> Unit, + appSettings: TodayUserSettings, + isOnMetered: Boolean, + scroll: LazyListState, + header: @Composable (Modifier) -> Unit, + footer: @Composable (Modifier) -> Unit, + modifier: Modifier = Modifier, +) { + + //no data handling + if (data.isEmpty()) { + NoItems(modifier, onNoItems) + return + } + + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + ) { + val preferredItemSize = min(maxWidth * 7 / 10, (196 + 32).dp) + + // showing items + LazyColumn( + verticalArrangement = Arrangement.spacedBy(Padding.MidSmall), + state = scroll, + ) { + item(key = "header") { + header(Modifier.animateItem()) + } + + data.forEach { category -> + item(key = category.name + "_cat_header") { + DishHeader( + courseType = category, + modifier = Modifier.padding(bottom = Padding.Smaller), + ) + } + item(key = category.name + "_content") { + + if (category.dishList.size == 1) { + val dish = category.dishList.first() + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + DishItem( + dish = dish, + onDishSelected = onDishSelected, + isInCarousel = false, + appSettings = appSettings, + isOnMetered = isOnMetered, + modifier = Modifier + .height(preferredItemSize), + ) + } + return@item + } + + val density = LocalDensity.current + val carouselState = rememberCarouselState { category.dishList.size } + HorizontalMultiBrowseCarousel( + state = carouselState, + preferredItemWidth = preferredItemSize, + modifier = Modifier + .fillMaxWidth() + .animateItem(), + minSmallItemWidth = 64.dp, + maxSmallItemWidth = 128.dp, + itemSpacing = Padding.MidSmall, + ) { index -> + val dish = category.dishList[index] + DishItem( + dish = dish, + onDishSelected = onDishSelected, + isInCarousel = true, + appSettings = appSettings, + isOnMetered = isOnMetered, + modifier = Modifier + .height(preferredItemSize) + .maskClip(MaterialTheme.shapes.extraLarge), + componentsGraphics = { alignment: Alignment.Vertical -> + val translationFactor = when (alignment) { + Alignment.Top -> -1f + Alignment.Bottom -> 1f + else -> error("Not supported") + } + { + val progress = carouselItemInfo.let { + // breakpoints + val visible = 0.9f + val hidden = 0.5f + + when (val ratio = it.size / it.maxSize) { + in visible..1f -> 1f + in hidden..visible -> (ratio - hidden) / (visible - hidden) + in 0.0f..hidden -> 0f + else -> 1f + }.coerceAtLeast(0f) + } + + // hides other surface components when they are not in foreground + alpha = progress + translationY = + with(density) { 48.dp.toPx() * (1f - progress) } * translationFactor + scaleX = progress / 2f + 0.5f + scaleY = progress / 2f + 0.5f + } + }, + ) + } + } + item(key = category.name + "_spacer") { + Spacer(Modifier.height(Padding.Small)) + } + } + + item(key = "footer") { + footer(Modifier.animateItem()) + } + } + } +} + +@Composable +private fun DishItem( + dish: Dish, + onDishSelected: (Dish) -> Unit, + isInCarousel: Boolean, + appSettings: TodayUserSettings, + isOnMetered: Boolean, + modifier: Modifier = Modifier, + componentsGraphics: ((Alignment.Vertical) -> (GraphicsLayerScope.() -> Unit))? = null, +) { + val ratio = if (isInCarousel) { + 1f + } else { + null + } + + Box( + modifier = modifier.clickable { onDishSelected(dish) }, + ) { + DishImageOrSupplement( + dish, + loadImmediately = loadImmediately(appSettings.downloadOnMetered, isOnMetered), + ratio = ratio, + ) + + val componentsModifier = Modifier + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = componentsModifier + .align(Alignment.BottomStart) + .padding(Padding.MidSmall) + .let { modifier -> + componentsGraphics + ?.invoke(Alignment.Bottom) + ?.let { modifier.graphicsLayer(it) } + ?: modifier + }, + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(Padding.Small), + modifier = Modifier.padding( + horizontal = Padding.MidSmall, + vertical = Padding.Small, + ), + ) { + Text( + dish.name, + modifier = Modifier + .basicMarquee(iterations = Int.MAX_VALUE) + .weight(1f), + maxLines = 1, + ) + DishBadge(dish, priceType = appSettings.priceType) + } + } + + dish.allergens?.let { allergens -> + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = componentsModifier + .align(Alignment.TopEnd) + .padding(Padding.MidSmall) + .let { modifier -> + componentsGraphics + ?.invoke(Alignment.Top) + ?.let { modifier.graphicsLayer(it) } ?: modifier + }, + ) { + Text( + text = allergens.joinToString(", "), + modifier = Modifier.padding(Padding.Small), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishHorizontal.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishHorizontal.kt index 24e768f7..0483f077 100644 --- a/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishHorizontal.kt +++ b/app/src/main/kotlin/cz/lastaapps/menza/features/today/ui/widget/TodayDishHorizontal.kt @@ -26,8 +26,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyColumn @@ -197,6 +199,9 @@ private fun DishContent( } } } + item(key = category.name + "_spacer") { + Spacer(Modifier.height(Padding.Small)) + } } item(key = "oliver") { @@ -271,22 +276,14 @@ private fun DishImageWithBadge( modifier: Modifier = Modifier, ) { Box(modifier) { - dish.photoLink?.let { photoLink -> - DishImageRatio( - photoLink = photoLink, - loadImmediately = downloadOnMetered || !isOnMetered, - modifier = Modifier - .align(Alignment.Center) - .padding(Padding.Small), - ) - } ?: run { - DishImageSupplement( - dish.name.hashCode(), - modifier = Modifier - .align(Alignment.Center) - .padding(Padding.Small), - ) - } + DishImageOrSupplement( + dish = dish, + loadImmediately = loadImmediately(downloadOnMetered, isOnMetered), + modifier = Modifier + .align(Alignment.Center) + .padding(Padding.Small), + ) + DishBadge( dish = dish, priceType = priceType, diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9de00116..b58eb54b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -202,6 +202,7 @@ Klasika Velké Řádky + Kolotoč Stabilní výška řádky Další (server odeslal špatná data) Experimentální menza - nemusí fungovat správně. Prosím, hlašte chyby v nastavení. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08aa4a5a..eb9f6335 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -218,6 +218,7 @@ Compact Large Rows + Carousel Stable row height Other (web sent wrong data) Experimental cafeteria - may not work properly. Please report issues in the settings.