Compare commits
2 Commits
c017a8e777
...
22b43050d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 22b43050d6 | |||
| 579504b927 |
@@ -68,4 +68,12 @@
|
|||||||
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
|
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
|
||||||
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
|
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
|
||||||
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
|
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
|
||||||
|
|
||||||
|
<!-- Bottom calendar pill (planer / spiżarnia / zakupy) -->
|
||||||
|
<string name="calendar_horizon_today">Tylko dziś</string>
|
||||||
|
<string name="calendar_horizon_days">Najbliższe %1$d dni</string>
|
||||||
|
|
||||||
|
<!-- Dummy metryki pilla (UI-first; realne dane w fazach 8/9) -->
|
||||||
|
<string name="pantry_shortfall_count">%1$d braków</string>
|
||||||
|
<string name="shopping_buy_count">%1$d do kupienia</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package dev.ulfrx.recipe.di
|
|||||||
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
|
import dev.ulfrx.recipe.ui.screens.home.HomeViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
|
||||||
|
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
import org.koin.plugin.module.dsl.viewModel
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package dev.ulfrx.recipe.ui.components.calendar
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -15,20 +16,17 @@ import androidx.compose.foundation.text.BasicText
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.composeunstyled.UnstyledButton
|
import com.composeunstyled.UnstyledButton
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
|
||||||
* Single day cell — circle, optional outline ring for "today", optional fill
|
|
||||||
* for "selected", optional dot indicator below the date number. Disabled days
|
|
||||||
* render as a non-interactive box.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CalendarDayCell(
|
internal fun CalendarDayCell(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
@@ -37,48 +35,56 @@ internal fun CalendarDayCell(
|
|||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
numberStyle: TextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light),
|
||||||
|
cellHeight: Dp = 36.dp,
|
||||||
|
header: String? = null,
|
||||||
|
headerStyle: TextStyle =
|
||||||
|
RecipeTheme.typography.label.copy(
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
lineHeight = 10.sp,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val colors = RecipeTheme.colors
|
val colors = RecipeTheme.colors
|
||||||
val baseColor = colors.content
|
val baseColor = colors.content
|
||||||
val mutedColor = colors.contentMuted
|
val mutedColor = colors.contentMuted
|
||||||
val accent = colors.accent
|
val accent = colors.accent
|
||||||
|
|
||||||
val background: Color =
|
val background = if (isSelected) accent.copy(alpha = 0.18f) else Color.Transparent
|
||||||
when {
|
val textColor =
|
||||||
isSelected -> accent.copy(alpha = 0.18f)
|
|
||||||
else -> Color.Transparent
|
|
||||||
}
|
|
||||||
val textColor: Color =
|
|
||||||
when {
|
when {
|
||||||
state.disabled -> mutedColor.copy(alpha = 0.45f)
|
state.disabled -> mutedColor.copy(alpha = 0.45f)
|
||||||
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
|
state.dimmed && !isSelected -> mutedColor.copy(alpha = 0.55f)
|
||||||
isSelected -> accent
|
isSelected -> accent
|
||||||
else -> baseColor
|
else -> baseColor
|
||||||
}
|
}
|
||||||
val ringColor: Color =
|
val headerColor =
|
||||||
|
if (isSelected || state.dimmed || state.disabled) textColor else mutedColor
|
||||||
|
val ringColor =
|
||||||
when {
|
when {
|
||||||
isSelected -> accent.copy(alpha = 0.55f)
|
isSelected -> accent.copy(alpha = 0.55f)
|
||||||
isToday -> baseColor.copy(alpha = 0.35f)
|
isToday -> baseColor.copy(alpha = 0.35f)
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
|
val indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = INDICATOR_MUTED_ALPHA)
|
||||||
|
|
||||||
val cellModifier =
|
val cellModifier = modifier.height(cellHeight).fillMaxWidth()
|
||||||
modifier
|
val isClickable = LocalCalendarInteractive.current && !state.disabled
|
||||||
.height(36.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
|
|
||||||
if (state.disabled) {
|
val content: @Composable () -> Unit = {
|
||||||
Box(modifier = cellModifier, contentAlignment = Alignment.Center) {
|
|
||||||
DayCellInner(
|
DayCellInner(
|
||||||
date = date,
|
date = date,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
|
numberStyle = numberStyle,
|
||||||
|
header = header,
|
||||||
|
headerStyle = headerStyle,
|
||||||
|
headerColor = headerColor,
|
||||||
indicator = state.indicator,
|
indicator = state.indicator,
|
||||||
indicatorColor = mutedColor.copy(alpha = 0.5f),
|
indicatorColor = indicatorColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (isClickable) {
|
||||||
UnstyledButton(
|
UnstyledButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
backgroundColor = background,
|
backgroundColor = background,
|
||||||
@@ -87,12 +93,13 @@ internal fun CalendarDayCell(
|
|||||||
borderColor = ringColor,
|
borderColor = ringColor,
|
||||||
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
|
borderWidth = if (ringColor == Color.Transparent) 0.dp else 1.dp,
|
||||||
modifier = cellModifier,
|
modifier = cellModifier,
|
||||||
) {
|
content = { content() },
|
||||||
DayCellInner(
|
)
|
||||||
date = date,
|
} else {
|
||||||
textColor = textColor,
|
Box(
|
||||||
indicator = state.indicator,
|
modifier = cellModifier.dayCellSurface(background, ringColor),
|
||||||
indicatorColor = if (isSelected) accent else mutedColor.copy(alpha = 0.65f),
|
contentAlignment = Alignment.Center,
|
||||||
|
content = { content() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,31 +108,120 @@ internal fun CalendarDayCell(
|
|||||||
private fun DayCellInner(
|
private fun DayCellInner(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
|
numberStyle: TextStyle,
|
||||||
|
header: String?,
|
||||||
|
headerStyle: TextStyle,
|
||||||
|
headerColor: Color,
|
||||||
indicator: Boolean,
|
indicator: Boolean,
|
||||||
indicatorColor: Color,
|
indicatorColor: Color,
|
||||||
) {
|
) {
|
||||||
Column(
|
if (header == null) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
CenteredDayNumber(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
date = date,
|
||||||
verticalArrangement = Arrangement.Center,
|
textColor = textColor,
|
||||||
|
numberStyle = numberStyle,
|
||||||
|
indicator = indicator,
|
||||||
|
indicatorColor = indicatorColor,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HeaderDayNumber(
|
||||||
|
date = date,
|
||||||
|
textColor = textColor,
|
||||||
|
numberStyle = numberStyle,
|
||||||
|
header = header,
|
||||||
|
headerStyle = headerStyle,
|
||||||
|
headerColor = headerColor,
|
||||||
|
indicator = indicator,
|
||||||
|
indicatorColor = indicatorColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredDayNumber(
|
||||||
|
date: LocalDate,
|
||||||
|
textColor: Color,
|
||||||
|
numberStyle: TextStyle,
|
||||||
|
indicator: Boolean,
|
||||||
|
indicatorColor: Color,
|
||||||
) {
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = date.dayOfMonth.toString(),
|
text = date.dayOfMonth.toString(),
|
||||||
style =
|
style = numberStyle.copy(color = textColor),
|
||||||
RecipeTheme.typography.label.copy(
|
modifier = Modifier.align(Alignment.Center),
|
||||||
color = textColor,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
IndicatorDot(
|
||||||
Box(
|
color = indicatorColor,
|
||||||
modifier =
|
modifier = Modifier.align(Alignment.Center).offset(y = 11.dp),
|
||||||
Modifier
|
|
||||||
.size(4.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(indicatorColor),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeaderDayNumber(
|
||||||
|
date: LocalDate,
|
||||||
|
textColor: Color,
|
||||||
|
numberStyle: TextStyle,
|
||||||
|
header: String,
|
||||||
|
headerStyle: TextStyle,
|
||||||
|
headerColor: Color,
|
||||||
|
indicator: Boolean,
|
||||||
|
indicatorColor: Color,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
BasicText(text = header, style = headerStyle.copy(color = headerColor))
|
||||||
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
|
BasicText(
|
||||||
|
text = date.dayOfMonth.toString(),
|
||||||
|
style = numberStyle.copy(color = textColor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (indicator) {
|
||||||
|
IndicatorDot(
|
||||||
|
color = indicatorColor,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IndicatorDot(
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.size(4.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.dayCellSurface(
|
||||||
|
backgroundColor: Color,
|
||||||
|
ringColor: Color,
|
||||||
|
): Modifier =
|
||||||
|
this
|
||||||
|
.background(backgroundColor, CircleShape)
|
||||||
|
.then(
|
||||||
|
if (ringColor == Color.Transparent) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, ringColor, CircleShape)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val INDICATOR_MUTED_ALPHA = 0.6f
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.calendar
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
import recipe.composeapp.generated.resources.calendar_horizon_days
|
||||||
|
import recipe.composeapp.generated.resources.calendar_horizon_today
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun horizonLabel(
|
||||||
|
today: LocalDate,
|
||||||
|
end: LocalDate,
|
||||||
|
): String {
|
||||||
|
val days = (today.daysUntil(end) + 1).coerceAtLeast(1)
|
||||||
|
return if (days == 1) {
|
||||||
|
stringResource(Res.string.calendar_horizon_today)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.calendar_horizon_days, days)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.text.BasicText
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -37,6 +38,7 @@ internal fun WeekdayHeader(
|
|||||||
style =
|
style =
|
||||||
RecipeTheme.typography.label.copy(
|
RecipeTheme.typography.label.copy(
|
||||||
color = RecipeTheme.colors.contentMuted,
|
color = RecipeTheme.colors.contentMuted,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.calendar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import dev.ulfrx.recipe.ui.theme.lerp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalendarPill(
|
||||||
|
expanded: Boolean,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onSelectDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String = "",
|
||||||
|
collapsedContent: (@Composable RowScope.() -> Unit)? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
dayState: (LocalDate) -> DayState = { DayState() },
|
||||||
|
pillHeight: Dp = 48.dp,
|
||||||
|
locale: CalendarLocale = CalendarLocale.PL,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val expansion = remember { PillExpansion(initial = if (expanded) 1f else 0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(expanded) {
|
||||||
|
expansion.animateTo(scope, target = if (expanded) 1f else 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val progress = expansion.progress
|
||||||
|
val cornerRadius = pillHeight / 2 * (1f - progress) + EXPANDED_CORNER_RADIUS * progress
|
||||||
|
val glassStyle = lerp(RecipeTheme.glass.menu, RecipeTheme.glass.panel, progress)
|
||||||
|
val pillInset = RecipeTheme.spacing.lg + RecipeTheme.spacing.xs
|
||||||
|
val pillHeightPx = with(LocalDensity.current) { pillHeight.toPx() }
|
||||||
|
val dragState =
|
||||||
|
rememberDraggableState { delta ->
|
||||||
|
expansion.dragBy(delta, range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
GlassSurface(
|
||||||
|
modifier =
|
||||||
|
modifier.draggable(
|
||||||
|
state = dragState,
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
onDragStarted = { expansion.cancelSettle() },
|
||||||
|
onDragStopped = { velocity ->
|
||||||
|
val openTarget = releaseTarget(expansion.progress, velocity)
|
||||||
|
val range = (expansion.fullHeightPx - pillHeightPx).coerceAtLeast(1f)
|
||||||
|
expansion.animateTo(scope, if (openTarget) 1f else 0f, initialVelocity = -velocity / range)
|
||||||
|
if (openTarget != expanded) onExpandedChange(openTarget)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
|
glassStyle = glassStyle,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
CompositionLocalProvider(LocalCalendarInteractive provides expanded) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().expandingHeight(progress, pillHeight, expansion).alpha(progress),
|
||||||
|
) {
|
||||||
|
SwipeableCalendar(
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
today = today,
|
||||||
|
mode = CalendarMode.Month,
|
||||||
|
onSelectDate = onSelectDate,
|
||||||
|
onModeChange = {},
|
||||||
|
onVisibleAnchorChange = {},
|
||||||
|
dayState = dayState,
|
||||||
|
expandable = false,
|
||||||
|
locale = locale,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = RecipeTheme.spacing.lg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rowAlpha = (1f - progress / PILL_CONTENT_FADE_END).coerceIn(0f, 1f)
|
||||||
|
if (rowAlpha > 0f) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.alpha(rowAlpha),
|
||||||
|
) {
|
||||||
|
PillRow(
|
||||||
|
label = label,
|
||||||
|
collapsedContent = collapsedContent,
|
||||||
|
trailing = trailing,
|
||||||
|
height = pillHeight,
|
||||||
|
horizontalInset = pillInset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PillRow(
|
||||||
|
label: String,
|
||||||
|
collapsedContent: (@Composable RowScope.() -> Unit)?,
|
||||||
|
trailing: (@Composable () -> Unit)?,
|
||||||
|
height: Dp,
|
||||||
|
horizontalInset: Dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = horizontalInset),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (collapsedContent != null) {
|
||||||
|
collapsedContent()
|
||||||
|
} else {
|
||||||
|
BasicText(
|
||||||
|
text = label,
|
||||||
|
style = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures the calendar at its full intrinsic height, reports it to [expansion]
|
||||||
|
* so drag knows the range, then lays out at the lerped height anchored to the
|
||||||
|
* bottom edge so the calendar slides down from above the pill row.
|
||||||
|
*/
|
||||||
|
private fun Modifier.expandingHeight(
|
||||||
|
progress: Float,
|
||||||
|
pillHeight: Dp,
|
||||||
|
expansion: PillExpansion,
|
||||||
|
): Modifier =
|
||||||
|
this.layout { measurable, constraints ->
|
||||||
|
val placeable =
|
||||||
|
measurable.measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
|
||||||
|
expansion.reportFullHeight(placeable.height)
|
||||||
|
val pillHeightPx = pillHeight.roundToPx()
|
||||||
|
val height = lerp(pillHeightPx, placeable.height, progress).coerceIn(pillHeightPx, placeable.height)
|
||||||
|
layout(placeable.width, height) {
|
||||||
|
placeable.place(0, height - placeable.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for pill drag/settle state. Holds [progress] (0 =
|
||||||
|
* collapsed, 1 = expanded) and tracks [target] so external [expanded] changes
|
||||||
|
* that match an in-flight settle become no-ops — no flag, no race.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
private class PillExpansion(initial: Float) {
|
||||||
|
var progress by mutableFloatStateOf(initial)
|
||||||
|
private set
|
||||||
|
var fullHeightPx by mutableIntStateOf(0)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var target: Float = initial
|
||||||
|
private var settleJob: Job? = null
|
||||||
|
|
||||||
|
fun dragBy(delta: Float, range: Float) {
|
||||||
|
settleJob?.cancel()
|
||||||
|
progress = (progress - delta / range).coerceIn(0f, 1f)
|
||||||
|
target = progress
|
||||||
|
}
|
||||||
|
|
||||||
|
fun animateTo(scope: CoroutineScope, target: Float, initialVelocity: Float = 0f) {
|
||||||
|
if (this.target == target && settleJob?.isActive == true) return
|
||||||
|
this.target = target
|
||||||
|
settleJob?.cancel()
|
||||||
|
settleJob =
|
||||||
|
scope.launch {
|
||||||
|
Animatable(progress).also { it.updateBounds(0f, 1f) }
|
||||||
|
.animateTo(
|
||||||
|
targetValue = target,
|
||||||
|
animationSpec =
|
||||||
|
spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
),
|
||||||
|
initialVelocity = initialVelocity,
|
||||||
|
) { progress = value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSettle() {
|
||||||
|
settleJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportFullHeight(height: Int) {
|
||||||
|
if (fullHeightPx != height) fullHeightPx = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseTarget(
|
||||||
|
progress: Float,
|
||||||
|
velocity: Float,
|
||||||
|
): Boolean =
|
||||||
|
when {
|
||||||
|
velocity <= -FLING_VELOCITY -> true
|
||||||
|
velocity >= FLING_VELOCITY -> false
|
||||||
|
else -> progress >= 0.5f
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FLING_VELOCITY = 60f
|
||||||
|
private const val PILL_CONTENT_FADE_END = 0.35f
|
||||||
|
private val EXPANDED_CORNER_RADIUS = 28.dp
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.ulfrx.recipe.ui.components.calendar
|
package dev.ulfrx.recipe.ui.components.calendar
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the calendar shows a single week strip or the full month grid.
|
* Whether the calendar shows a single week strip or the full month grid.
|
||||||
@@ -8,6 +9,13 @@ import androidx.compose.runtime.Immutable
|
|||||||
*/
|
*/
|
||||||
enum class CalendarMode { Week, Month }
|
enum class CalendarMode { Week, Month }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-cell interactivity gate. CalendarPill flips this to `false` while
|
||||||
|
* collapsed so the always-composed month grid (kept in the tree to feed drag
|
||||||
|
* its full height) doesn't catch taps that visually belong to the pill row.
|
||||||
|
*/
|
||||||
|
internal val LocalCalendarInteractive = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-day visual modifiers resolved by the caller. Selection and "today"
|
* Per-day visual modifiers resolved by the caller. Selection and "today"
|
||||||
* outline are handled by the surface itself and must not be set here.
|
* outline are handled by the surface itself and must not be set here.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.calendar
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.datetime.DatePeriod
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class HorizonCalendarState(
|
||||||
|
val selectedDate: LocalDate,
|
||||||
|
val isCalendarOpen: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared state holder for "pick a horizon date" screens (Pantry, Shopping).
|
||||||
|
* Owns the date + open flag and enforces "no past dates" on selection. Lives
|
||||||
|
* inside the owning ViewModel as a plain field — not a ViewModel itself.
|
||||||
|
*
|
||||||
|
* [today] is parameterised so tests can pin the clock.
|
||||||
|
*/
|
||||||
|
class HorizonCalendarHolder(
|
||||||
|
initialDate: LocalDate = defaultHorizon(),
|
||||||
|
private val today: () -> LocalDate = ::todayInSystemTz,
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(HorizonCalendarState(selectedDate = initialDate))
|
||||||
|
val state: StateFlow<HorizonCalendarState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun setOpen(open: Boolean) {
|
||||||
|
_state.update { it.copy(isCalendarOpen = open) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() = setOpen(false)
|
||||||
|
|
||||||
|
fun select(date: LocalDate) {
|
||||||
|
if (date < today()) return
|
||||||
|
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_HORIZON_DAYS = 7
|
||||||
|
|
||||||
|
fun defaultHorizon(today: LocalDate = todayInSystemTz()): LocalDate =
|
||||||
|
today.plus(DatePeriod(days = DEFAULT_HORIZON_DAYS - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.calendar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HorizonCalendarPill(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
expanded: Boolean,
|
||||||
|
today: LocalDate,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
onSelectDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailing: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
CalendarPill(
|
||||||
|
label = horizonLabel(today, selectedDate),
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = onExpandedChange,
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
today = today,
|
||||||
|
onSelectDate = onSelectDate,
|
||||||
|
trailing = trailing,
|
||||||
|
dayState = { date ->
|
||||||
|
if (date < today) DayState(disabled = true, dimmed = true) else DayState()
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.overlay
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||||
|
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scaffold for a bottom-anchored modal overlay (calendar pill today; future
|
||||||
|
* bottom-sheets, filter panels). Owns four crosscuts so screens don't repeat
|
||||||
|
* them:
|
||||||
|
* - **Local glass backdrop** — Liquid refraction filters the nearest
|
||||||
|
* liquefiable ancestor, so the overlay must be a sibling of its own
|
||||||
|
* backdrop source (not a descendant of the shell's global one).
|
||||||
|
* - **Scrim** — tap-outside dismisses while [open] is true.
|
||||||
|
* - **Tab/route exit** — closes the overlay on dispose to keep state honest
|
||||||
|
* when the user navigates away mid-open.
|
||||||
|
* - **Active-tab tap** — registers with [OverlayDismisser] so a tap on the
|
||||||
|
* already-active tab in the shell closes us too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BottomOverlayScaffold(
|
||||||
|
open: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
bottomInset: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
overlay: @Composable () -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val backdrop = rememberGlassBackdropState()
|
||||||
|
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
val latestOpen by rememberUpdatedState(open)
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { if (latestOpen) latestOnDismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterDismissibleOverlay(active = open, onDismiss = onDismiss)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalGlassBackdropState provides backdrop) {
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
GlassBackdropSource(state = backdrop, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background)) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { onDismiss() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = RecipeTheme.spacing.xl)
|
||||||
|
.padding(bottom = bottomInset),
|
||||||
|
) {
|
||||||
|
overlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.components.overlay
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class OverlayDismisser {
|
||||||
|
private val handlers = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
|
fun register(onDismiss: () -> Unit): () -> Unit {
|
||||||
|
handlers += onDismiss
|
||||||
|
return { handlers -= onDismiss }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAll() {
|
||||||
|
handlers.toList().forEach { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalOverlayDismisser = staticCompositionLocalOf<OverlayDismisser> {
|
||||||
|
error("OverlayDismisser not provided — wrap your composable in AppShell or supply one explicitly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterDismissibleOverlay(
|
||||||
|
active: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dismisser = LocalOverlayDismisser.current
|
||||||
|
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
DisposableEffect(dismisser, active) {
|
||||||
|
val unregister = if (active) dismisser.register { latestOnDismiss() } else null
|
||||||
|
onDispose { unregister?.invoke() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.screens.pantry
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
import recipe.composeapp.generated.resources.pantry_shortfall_count
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PantryHorizonPill(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
expanded: Boolean,
|
||||||
|
today: LocalDate,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
onSelectDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
HorizonCalendarPill(
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
expanded = expanded,
|
||||||
|
today = today,
|
||||||
|
onExpandedChange = onExpandedChange,
|
||||||
|
onSelectDate = onSelectDate,
|
||||||
|
trailing = {
|
||||||
|
BasicText(
|
||||||
|
text = stringResource(Res.string.pantry_shortfall_count, DUMMY_SHORTFALLS),
|
||||||
|
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.destructive),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DUMMY_SHORTFALLS = 7
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.pantry
|
package dev.ulfrx.recipe.ui.screens.pantry
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.DockDestination
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
|
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
|
||||||
|
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_pantry_subtitle
|
|||||||
import recipe.composeapp.generated.resources.empty_pantry_title
|
import recipe.composeapp.generated.resources.empty_pantry_title
|
||||||
import recipe.composeapp.generated.resources.shell_tab_pantry
|
import recipe.composeapp.generated.resources.shell_tab_pantry
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 2.1 — empty-state screen for the Pantry tab. Phase 8 replaces the
|
|
||||||
* empty body with the inventory list.
|
|
||||||
*
|
|
||||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PantryScreen(viewModel: PantryViewModel) {
|
fun PantryScreen(viewModel: PantryViewModel) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val today = remember { todayInSystemTz() }
|
||||||
|
|
||||||
Box(
|
BottomOverlayScaffold(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
open = horizonState.isCalendarOpen,
|
||||||
|
onDismiss = viewModel.horizon::close,
|
||||||
|
bottomInset = rememberShellChromeHeight(),
|
||||||
|
overlay = {
|
||||||
|
PantryHorizonPill(
|
||||||
|
selectedDate = horizonState.selectedDate,
|
||||||
|
expanded = horizonState.isCalendarOpen,
|
||||||
|
today = today,
|
||||||
|
onExpandedChange = viewModel.horizon::setOpen,
|
||||||
|
onSelectDate = viewModel.horizon::select,
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.pantry
|
package dev.ulfrx.recipe.ui.screens.pantry
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI state for [PantryScreen]. Phase 2.1 ships only the empty state. Phase 8
|
|
||||||
* (Pantry) extends this with inventory rows + actions.
|
|
||||||
*/
|
|
||||||
data class PantryState(
|
|
||||||
val isEmpty: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
class PantryViewModel : ViewModel() {
|
class PantryViewModel : ViewModel() {
|
||||||
private val _state = MutableStateFlow(PantryState())
|
val horizon = HorizonCalendarHolder()
|
||||||
val state: StateFlow<PantryState> = _state.asStateFlow()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.screens.planner
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.CalendarLocale
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.CalendarPill
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.DayState
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import kotlinx.datetime.DatePeriod
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlannerCalendarPill(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
expanded: Boolean,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
onSelectDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val today = remember { todayInSystemTz() }
|
||||||
|
val locale = CalendarLocale.PL
|
||||||
|
val plannedDummy =
|
||||||
|
remember(today) {
|
||||||
|
setOf(today, today.plus(DatePeriod(days = 1)), today.plus(DatePeriod(days = 3)))
|
||||||
|
}
|
||||||
|
val dayState =
|
||||||
|
remember(plannedDummy) {
|
||||||
|
{ date: LocalDate -> DayState(indicator = date in plannedDummy) }
|
||||||
|
}
|
||||||
|
val pillTextStyle = RecipeTheme.typography.label.copy(fontWeight = FontWeight.Light, fontSize = 12.sp)
|
||||||
|
|
||||||
|
CalendarPill(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = onExpandedChange,
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
today = today,
|
||||||
|
onSelectDate = onSelectDate,
|
||||||
|
collapsedContent = {
|
||||||
|
PlannerWeekStrip(
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
today = today,
|
||||||
|
onSelectDate = onSelectDate,
|
||||||
|
numberStyle = pillTextStyle,
|
||||||
|
dayState = dayState,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
BasicText(
|
||||||
|
text = locale.monthsShort[selectedDate.monthNumber - 1],
|
||||||
|
style = pillTextStyle.copy(color = RecipeTheme.colors.contentMuted),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dayState = dayState,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,81 +1,66 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.planner
|
package dev.ulfrx.recipe.ui.screens.planner
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.ui.components.calendar.SwipeableCalendar
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
|
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
|
||||||
|
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
import recipe.composeapp.generated.resources.empty_planner_subtitle
|
||||||
|
import recipe.composeapp.generated.resources.empty_planner_title
|
||||||
import recipe.composeapp.generated.resources.shell_tab_planner
|
import recipe.composeapp.generated.resources.shell_tab_planner
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 2.1 — planner shell with the shared calendar at the top. Phase 6 fills
|
|
||||||
* in the area below the calendar with meal slots driven by [PlannerState.selectedDate].
|
|
||||||
*
|
|
||||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlannerScreen(viewModel: PlannerViewModel) {
|
fun PlannerScreen(viewModel: PlannerViewModel) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val today = remember { todayInSystemTz() }
|
|
||||||
|
|
||||||
Box(
|
BottomOverlayScaffold(
|
||||||
modifier =
|
open = state.isCalendarOpen,
|
||||||
Modifier
|
onDismiss = viewModel::closeCalendar,
|
||||||
.fillMaxSize()
|
bottomInset = rememberShellChromeHeight(),
|
||||||
.background(RecipeTheme.colors.background),
|
overlay = {
|
||||||
|
PlannerCalendarPill(
|
||||||
|
selectedDate = state.selectedDate,
|
||||||
|
expanded = state.isCalendarOpen,
|
||||||
|
onExpandedChange = viewModel::setCalendarOpen,
|
||||||
|
onSelectDate = viewModel::selectDate,
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.windowInsetsPadding(WindowInsets.statusBars),
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
.padding(top = RecipeTheme.spacing.xl),
|
||||||
|
verticalArrangement = Arrangement.Top,
|
||||||
) {
|
) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = stringResource(Res.string.shell_tab_planner),
|
text = stringResource(Res.string.shell_tab_planner),
|
||||||
style =
|
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
|
||||||
RecipeTheme.typography.title.copy(
|
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
|
||||||
color = RecipeTheme.colors.content,
|
|
||||||
),
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(
|
|
||||||
top = RecipeTheme.spacing.xl,
|
|
||||||
start = RecipeTheme.spacing.lg,
|
|
||||||
end = RecipeTheme.spacing.lg,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(modifier = Modifier.height(RecipeTheme.spacing.lg))
|
EmptyState(
|
||||||
|
icon = DockDestination.Planner.icon,
|
||||||
SwipeableCalendar(
|
title = stringResource(Res.string.empty_planner_title),
|
||||||
selectedDate = state.selectedDate,
|
subtitle = stringResource(Res.string.empty_planner_subtitle),
|
||||||
today = today,
|
|
||||||
mode = state.calendarMode,
|
|
||||||
onSelectDate = viewModel::selectDate,
|
|
||||||
onModeChange = viewModel::setCalendarMode,
|
|
||||||
// Swipe auto-follows: dropping into a new week/month bumps
|
|
||||||
// the selection by the same offset (kotlinx.datetime clamps
|
|
||||||
// day-of-month for short months).
|
|
||||||
onVisibleAnchorChange = viewModel::selectDate,
|
|
||||||
expandable = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.planner
|
package dev.ulfrx.recipe.ui.screens.planner
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import dev.ulfrx.recipe.ui.components.calendar.CalendarMode
|
|
||||||
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -9,31 +8,24 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
|
||||||
* UI state for [PlannerScreen]. Phase 2.1 ships only the calendar; Phase 6
|
|
||||||
* extends this with day-plan data, meal slot actions, and pantry/shortfall
|
|
||||||
* derivations driven by [selectedDate].
|
|
||||||
*/
|
|
||||||
data class PlannerState(
|
data class PlannerState(
|
||||||
val selectedDate: LocalDate,
|
val selectedDate: LocalDate,
|
||||||
val calendarMode: CalendarMode,
|
val isCalendarOpen: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlannerViewModel : ViewModel() {
|
class PlannerViewModel : ViewModel() {
|
||||||
private val _state =
|
private val _state = MutableStateFlow(PlannerState(selectedDate = todayInSystemTz()))
|
||||||
MutableStateFlow(
|
|
||||||
PlannerState(
|
|
||||||
selectedDate = todayInSystemTz(),
|
|
||||||
calendarMode = CalendarMode.Week,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val state: StateFlow<PlannerState> = _state.asStateFlow()
|
val state: StateFlow<PlannerState> = _state.asStateFlow()
|
||||||
|
|
||||||
fun selectDate(date: LocalDate) {
|
fun selectDate(date: LocalDate) {
|
||||||
_state.update { it.copy(selectedDate = date) }
|
_state.update { it.copy(selectedDate = date, isCalendarOpen = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarMode(mode: CalendarMode) {
|
fun setCalendarOpen(open: Boolean) {
|
||||||
_state.update { it.copy(calendarMode = mode) }
|
_state.update { it.copy(isCalendarOpen = open) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeCalendar() {
|
||||||
|
_state.update { it.copy(isCalendarOpen = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.screens.planner
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.CalendarDayCell
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.CalendarLocale
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.DayState
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.weekStripDays
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlannerWeekStrip(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onSelectDate: (LocalDate) -> Unit,
|
||||||
|
numberStyle: TextStyle,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dayState: (LocalDate) -> DayState = { DayState() },
|
||||||
|
locale: CalendarLocale = CalendarLocale.PL,
|
||||||
|
) {
|
||||||
|
val days = weekStripDays(selectedDate)
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
days.forEachIndexed { index, day ->
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
CalendarDayCell(
|
||||||
|
date = day,
|
||||||
|
state = dayState(day),
|
||||||
|
isSelected = day == selectedDate,
|
||||||
|
isToday = day == today,
|
||||||
|
onClick = { onSelectDate(day) },
|
||||||
|
numberStyle = numberStyle,
|
||||||
|
header = locale.weekdaysShort[index],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,14 +109,14 @@ fun RecipeDetailSheet(
|
|||||||
|
|
||||||
ModalBottomSheet(state = sheetState) {
|
ModalBottomSheet(state = sheetState) {
|
||||||
Scrim(
|
Scrim(
|
||||||
scrimColor = ScrimColor,
|
scrimColor = SCRIM_COLOR,
|
||||||
enter = fadeIn(tween(ScrimFadeMillis)),
|
enter = fadeIn(tween(SCRIM_FADE_MILLIS)),
|
||||||
exit = fadeOut(tween(ScrimFadeMillis)),
|
exit = fadeOut(tween(SCRIM_FADE_MILLIS)),
|
||||||
)
|
)
|
||||||
Sheet(
|
Sheet(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
backgroundColor = RecipeTheme.colors.background,
|
backgroundColor = RecipeTheme.colors.background,
|
||||||
shape = RoundedCornerShape(topStart = SheetCornerRadius, topEnd = SheetCornerRadius),
|
shape = RoundedCornerShape(topStart = SHEET_CORNER_RADIUS, topEnd = SHEET_CORNER_RADIUS),
|
||||||
) {
|
) {
|
||||||
ready?.let {
|
ready?.let {
|
||||||
RecipeDetailContent(
|
RecipeDetailContent(
|
||||||
@@ -161,8 +161,8 @@ private fun BottomSheetScope.RecipeDetailContent(
|
|||||||
style =
|
style =
|
||||||
typography.display.copy(
|
typography.display.copy(
|
||||||
color = colors.content,
|
color = colors.content,
|
||||||
fontSize = TitleTextSize,
|
fontSize = TITLE_TEXT_SIZE,
|
||||||
lineHeight = TitleLineHeight,
|
lineHeight = TITLE_LINE_HEIGHT,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -199,8 +199,8 @@ private fun BottomSheetScope.RecipeDetailContent(
|
|||||||
.semantics { contentDescription = handleLabel }
|
.semantics { contentDescription = handleLabel }
|
||||||
.clip(RoundedCornerShape(percent = 50))
|
.clip(RoundedCornerShape(percent = 50))
|
||||||
.background(colors.surface.copy(alpha = 0.85f))
|
.background(colors.surface.copy(alpha = 0.85f))
|
||||||
.width(HandleWidth)
|
.width(HANDLE_WIDTH)
|
||||||
.height(HandleHeight),
|
.height(HANDLE_HEIGHT),
|
||||||
)
|
)
|
||||||
|
|
||||||
PlanButton(
|
PlanButton(
|
||||||
@@ -217,7 +217,7 @@ private fun BottomSheetScope.RecipeDetailContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RecipeHero() {
|
private fun RecipeHero() {
|
||||||
val colors = RecipeTheme.colors
|
val colors = RecipeTheme.colors
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(HeroHeight)) {
|
Box(modifier = Modifier.fillMaxWidth().height(HERO_HEIGHT)) {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass))
|
Box(modifier = Modifier.fillMaxSize().background(colors.surfaceGlass))
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(Res.drawable.sample_recipe),
|
painter = painterResource(Res.drawable.sample_recipe),
|
||||||
@@ -253,7 +253,7 @@ private fun MetaRow(minutes: Int) {
|
|||||||
imageVector = Lucide.Clock,
|
imageVector = Lucide.Clock,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colors.contentMuted,
|
tint = colors.contentMuted,
|
||||||
modifier = Modifier.size(MetaIconSize),
|
modifier = Modifier.size(META_ICON_SIZE),
|
||||||
)
|
)
|
||||||
BasicText(
|
BasicText(
|
||||||
text = stringResource(Res.string.recipe_card_minutes_format, minutes),
|
text = stringResource(Res.string.recipe_card_minutes_format, minutes),
|
||||||
@@ -279,8 +279,8 @@ private fun SectionTitle(text: String) {
|
|||||||
style =
|
style =
|
||||||
RecipeTheme.typography.label.copy(
|
RecipeTheme.typography.label.copy(
|
||||||
color = RecipeTheme.colors.contentMuted,
|
color = RecipeTheme.colors.contentMuted,
|
||||||
fontSize = SectionHeaderTextSize,
|
fontSize = SECTION_HEADER_TEXT_SIZE,
|
||||||
letterSpacing = SectionHeaderTracking,
|
letterSpacing = SECTION_HEADER_TRACKING,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -320,7 +320,7 @@ private fun IngredientsSection(
|
|||||||
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
onSelectSubstitution: (slotId: String, optionId: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
|
Section(title = stringResource(Res.string.recipe_detail_section_ingredients)) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(IngredientRowGap)) {
|
Column(verticalArrangement = Arrangement.spacedBy(INGREDIENT_ROW_GAP)) {
|
||||||
ingredients.forEach { slot ->
|
ingredients.forEach { slot ->
|
||||||
IngredientRow(
|
IngredientRow(
|
||||||
slot = slot.scaledBy(servings),
|
slot = slot.scaledBy(servings),
|
||||||
@@ -377,7 +377,7 @@ private fun ServingsStepper(
|
|||||||
color = colors.content,
|
color = colors.content,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
),
|
),
|
||||||
modifier = Modifier.width(ServingsValueWidth),
|
modifier = Modifier.width(SERVINGS_VALUE_WIDTH),
|
||||||
)
|
)
|
||||||
StepperButton(
|
StepperButton(
|
||||||
icon = Lucide.Plus,
|
icon = Lucide.Plus,
|
||||||
@@ -399,14 +399,14 @@ private fun StepperButton(
|
|||||||
UnstyledButton(
|
UnstyledButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = Modifier.size(StepperButtonSize),
|
modifier = Modifier.size(STEPPER_BUTTON_SIZE),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
UnstyledIcon(
|
UnstyledIcon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
|
tint = if (enabled) colors.content else colors.contentMuted.copy(alpha = 0.45f),
|
||||||
modifier = Modifier.size(StepperIconSize),
|
modifier = Modifier.size(STEPPER_ICON_SIZE),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,9 +425,9 @@ private fun StepRow(
|
|||||||
RecipeTheme.typography.body.copy(
|
RecipeTheme.typography.body.copy(
|
||||||
color = colors.contentMuted,
|
color = colors.contentMuted,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontSize = StepTextSize,
|
fontSize = STEP_TEXT_SIZE,
|
||||||
),
|
),
|
||||||
modifier = Modifier.width(StepNumberWidth),
|
modifier = Modifier.width(STEP_NUMBER_WIDTH),
|
||||||
)
|
)
|
||||||
BasicText(
|
BasicText(
|
||||||
text = text,
|
text = text,
|
||||||
@@ -435,8 +435,8 @@ private fun StepRow(
|
|||||||
RecipeTheme.typography.body.copy(
|
RecipeTheme.typography.body.copy(
|
||||||
color = colors.content,
|
color = colors.content,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = StepTextSize,
|
fontSize = STEP_TEXT_SIZE,
|
||||||
lineHeight = StepLineHeight,
|
lineHeight = STEP_LINE_HEIGHT,
|
||||||
),
|
),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -450,8 +450,8 @@ private fun PlanButton(
|
|||||||
) {
|
) {
|
||||||
val colors = RecipeTheme.colors
|
val colors = RecipeTheme.colors
|
||||||
GlassSurface(
|
GlassSurface(
|
||||||
modifier = modifier.height(PlanButtonHeight),
|
modifier = modifier.height(PLAN_BUTTON_HEIGHT),
|
||||||
cornerRadius = PlanButtonHeight / 2,
|
cornerRadius = PLAN_BUTTON_HEIGHT / 2,
|
||||||
tint = colors.surfaceGlass,
|
tint = colors.surfaceGlass,
|
||||||
) {
|
) {
|
||||||
UnstyledButton(
|
UnstyledButton(
|
||||||
@@ -469,7 +469,7 @@ private fun PlanButton(
|
|||||||
imageVector = Lucide.Calendar,
|
imageVector = Lucide.Calendar,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colors.content,
|
tint = colors.content,
|
||||||
modifier = Modifier.size(PlanButtonIconSize),
|
modifier = Modifier.size(PLAN_BUTTON_ICON_SIZE),
|
||||||
)
|
)
|
||||||
BasicText(
|
BasicText(
|
||||||
text = stringResource(Res.string.recipe_detail_plan_button),
|
text = stringResource(Res.string.recipe_detail_plan_button),
|
||||||
@@ -485,25 +485,25 @@ private fun PlanButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val SHEET_HEIGHT_FRACTION = 0.92f
|
private const val SHEET_HEIGHT_FRACTION = 0.92f
|
||||||
private const val ScrimFadeMillis = 250
|
private const val SCRIM_FADE_MILLIS = 250
|
||||||
|
|
||||||
private val ScrimColor = Color.Black.copy(alpha = 0.45f)
|
private val SCRIM_COLOR = Color.Black.copy(alpha = 0.45f)
|
||||||
private val SheetCornerRadius = 28.dp
|
private val SHEET_CORNER_RADIUS = 28.dp
|
||||||
private val HeroHeight = 200.dp
|
private val HERO_HEIGHT = 200.dp
|
||||||
private val HandleWidth = 36.dp
|
private val HANDLE_WIDTH = 36.dp
|
||||||
private val HandleHeight = 5.dp
|
private val HANDLE_HEIGHT = 5.dp
|
||||||
private val IngredientRowGap = 6.dp
|
private val INGREDIENT_ROW_GAP = 6.dp
|
||||||
private val MetaIconSize = 14.dp
|
private val META_ICON_SIZE = 14.dp
|
||||||
private val StepperButtonSize = 30.dp
|
private val STEPPER_BUTTON_SIZE = 30.dp
|
||||||
private val StepperIconSize = 14.dp
|
private val STEPPER_ICON_SIZE = 14.dp
|
||||||
private val ServingsValueWidth = 28.dp
|
private val SERVINGS_VALUE_WIDTH = 28.dp
|
||||||
private val StepNumberWidth = 20.dp
|
private val STEP_NUMBER_WIDTH = 20.dp
|
||||||
private val PlanButtonHeight = 36.dp
|
private val PLAN_BUTTON_HEIGHT = 36.dp
|
||||||
private val PlanButtonIconSize = 14.dp
|
private val PLAN_BUTTON_ICON_SIZE = 14.dp
|
||||||
|
|
||||||
private val TitleTextSize = 24.sp
|
private val TITLE_TEXT_SIZE = 24.sp
|
||||||
private val TitleLineHeight = 28.sp
|
private val TITLE_LINE_HEIGHT = 28.sp
|
||||||
private val SectionHeaderTextSize = 11.sp
|
private val SECTION_HEADER_TEXT_SIZE = 11.sp
|
||||||
private val SectionHeaderTracking = 1.sp
|
private val SECTION_HEADER_TRACKING = 1.sp
|
||||||
private val StepTextSize = 14.sp
|
private val STEP_TEXT_SIZE = 14.sp
|
||||||
private val StepLineHeight = 20.sp
|
private val STEP_LINE_HEIGHT = 20.sp
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Search
|
import com.composables.icons.lucide.Search
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
|
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
|
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailSheet
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||||
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogGrid
|
||||||
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import dev.ulfrx.recipe.navigation.TabNavigator
|
|||||||
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
|
||||||
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackdropState
|
||||||
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
import dev.ulfrx.recipe.ui.components.glass.rememberGlassBackdropState
|
||||||
|
import dev.ulfrx.recipe.ui.components.overlay.LocalOverlayDismisser
|
||||||
|
import dev.ulfrx.recipe.ui.components.overlay.OverlayDismisser
|
||||||
|
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
import dev.ulfrx.recipe.ui.screens.search.SearchScreen
|
||||||
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
import dev.ulfrx.recipe.ui.screens.search.ShellSearchViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
import dev.ulfrx.recipe.ui.screens.search.catalog.RecipeCatalogViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.recipedetail.RecipeDetailViewModel
|
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@@ -40,8 +42,12 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
val catalogGridState = rememberLazyGridState()
|
val catalogGridState = rememberLazyGridState()
|
||||||
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
val searchState by searchVm.state.collectAsStateWithLifecycle()
|
||||||
val backdropState = rememberGlassBackdropState()
|
val backdropState = rememberGlassBackdropState()
|
||||||
|
val overlayDismisser = remember { OverlayDismisser() }
|
||||||
|
|
||||||
CompositionLocalProvider(LocalGlassBackdropState provides backdropState) {
|
CompositionLocalProvider(
|
||||||
|
LocalGlassBackdropState provides backdropState,
|
||||||
|
LocalOverlayDismisser provides overlayDismisser,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -79,7 +85,10 @@ fun AppShell(modifier: Modifier = Modifier) {
|
|||||||
|
|
||||||
ShellBottomChrome(
|
ShellBottomChrome(
|
||||||
activeTab = navigator.activeTab,
|
activeTab = navigator.activeTab,
|
||||||
onTabSelect = navigator::selectTab,
|
onTabSelect = { tab ->
|
||||||
|
overlayDismisser.dismissAll()
|
||||||
|
navigator.selectTab(tab)
|
||||||
|
},
|
||||||
search =
|
search =
|
||||||
SearchHandlers(
|
SearchHandlers(
|
||||||
state = searchState,
|
state = searchState,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ fun ShellBottomChrome(
|
|||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = search.state.isOpen,
|
targetState = search.state.isOpen,
|
||||||
modifier = Modifier.fillMaxWidth().height(63.dp),
|
modifier = Modifier.fillMaxWidth().height(DockBandHeight),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
// Exit is instant (no fade-out): the outgoing chrome cell — dock
|
// Exit is instant (no fade-out): the outgoing chrome cell — dock
|
||||||
// OR search pill row — may still be playing its press animation
|
// OR search pill row — may still be playing its press animation
|
||||||
@@ -165,9 +165,9 @@ private fun DockRow(
|
|||||||
collapsed = false,
|
collapsed = false,
|
||||||
onTabSelect = onTabSelect,
|
onTabSelect = onTabSelect,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
height = 63.dp,
|
height = DockBandHeight,
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.size(63.dp)) {
|
Box(modifier = Modifier.size(DockBandHeight)) {
|
||||||
FloatingSearchButton(onClick = onSearchTap)
|
FloatingSearchButton(onClick = onSearchTap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.screens.shell
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
|
||||||
|
internal val DockBandHeight: Dp = 63.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberShellChromeHeight(): Dp {
|
||||||
|
val spacing = RecipeTheme.spacing
|
||||||
|
val navBottom =
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
(WindowInsets.navigationBars.getBottom(this) / 2).toDp()
|
||||||
|
}
|
||||||
|
return navBottom + spacing.xs + DockBandHeight + spacing.sm
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.ulfrx.recipe.ui.screens.shopping
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarPill
|
||||||
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import recipe.composeapp.generated.resources.Res
|
||||||
|
import recipe.composeapp.generated.resources.shopping_buy_count
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShoppingHorizonPill(
|
||||||
|
selectedDate: LocalDate,
|
||||||
|
expanded: Boolean,
|
||||||
|
today: LocalDate,
|
||||||
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
|
onSelectDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
HorizonCalendarPill(
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
expanded = expanded,
|
||||||
|
today = today,
|
||||||
|
onExpandedChange = onExpandedChange,
|
||||||
|
onSelectDate = onSelectDate,
|
||||||
|
trailing = {
|
||||||
|
BasicText(
|
||||||
|
text = stringResource(Res.string.shopping_buy_count, DUMMY_TO_BUY),
|
||||||
|
style = RecipeTheme.typography.label.copy(color = RecipeTheme.colors.contentMuted),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DUMMY_TO_BUY = 12
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.shopping
|
package dev.ulfrx.recipe.ui.screens.shopping
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,10 +11,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.ulfrx.recipe.navigation.DockDestination
|
import dev.ulfrx.recipe.navigation.DockDestination
|
||||||
|
import dev.ulfrx.recipe.ui.components.calendar.todayInSystemTz
|
||||||
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
import dev.ulfrx.recipe.ui.components.empty.EmptyState
|
||||||
|
import dev.ulfrx.recipe.ui.components.overlay.BottomOverlayScaffold
|
||||||
|
import dev.ulfrx.recipe.ui.screens.shell.rememberShellChromeHeight
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -23,19 +26,24 @@ import recipe.composeapp.generated.resources.empty_shopping_subtitle
|
|||||||
import recipe.composeapp.generated.resources.empty_shopping_title
|
import recipe.composeapp.generated.resources.empty_shopping_title
|
||||||
import recipe.composeapp.generated.resources.shell_tab_shopping
|
import recipe.composeapp.generated.resources.shell_tab_shopping
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 2.1 — empty-state screen for the Shopping tab. Phase 9 replaces the
|
|
||||||
* empty body with the shopping list + session UI.
|
|
||||||
*
|
|
||||||
* Search is shell-wide; this screen owns no bottom-chrome state.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
fun ShoppingScreen(viewModel: ShoppingViewModel) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
val horizonState by viewModel.horizon.state.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val today = remember { todayInSystemTz() }
|
||||||
|
|
||||||
Box(
|
BottomOverlayScaffold(
|
||||||
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
|
open = horizonState.isCalendarOpen,
|
||||||
|
onDismiss = viewModel.horizon::close,
|
||||||
|
bottomInset = rememberShellChromeHeight(),
|
||||||
|
overlay = {
|
||||||
|
ShoppingHorizonPill(
|
||||||
|
selectedDate = horizonState.selectedDate,
|
||||||
|
expanded = horizonState.isCalendarOpen,
|
||||||
|
today = today,
|
||||||
|
onExpandedChange = viewModel.horizon::setOpen,
|
||||||
|
onSelectDate = viewModel.horizon::select,
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.shopping
|
package dev.ulfrx.recipe.ui.screens.shopping
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import dev.ulfrx.recipe.ui.components.calendar.HorizonCalendarHolder
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI state for [ShoppingScreen]. Phase 2.1 ships only the empty state. Phase 9
|
|
||||||
* (Shopping List & Session Log) extends this with list items + session actions.
|
|
||||||
*/
|
|
||||||
data class ShoppingState(
|
|
||||||
val isEmpty: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
class ShoppingViewModel : ViewModel() {
|
class ShoppingViewModel : ViewModel() {
|
||||||
private val _state = MutableStateFlow(ShoppingState())
|
val horizon = HorizonCalendarHolder()
|
||||||
val state: StateFlow<ShoppingState> = _state.asStateFlow()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ public val LightRecipeColors: RecipeColors =
|
|||||||
|
|
||||||
public val DarkRecipeColors: RecipeColors =
|
public val DarkRecipeColors: RecipeColors =
|
||||||
RecipeColors(
|
RecipeColors(
|
||||||
background = Color(0xFF0F1113),
|
background = Color(0xFF1E2024),
|
||||||
surface = Color(0xFF1A1D21),
|
surface = Color(0xFF2A2D31),
|
||||||
surfaceGlass = Color(0xFF3A3D42).copy(alpha = 0.55f),
|
surfaceGlass = Color(0xFF494D53).copy(alpha = 0.55f),
|
||||||
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.12f),
|
surfaceGlassOverlay = Color(0xFFFFFFFF).copy(alpha = 0.12f),
|
||||||
content = Color(0xFFF1EFEA),
|
content = Color(0xFFF1EFEA),
|
||||||
contentMuted = Color(0xFF9AA0A6),
|
contentMuted = Color(0xFF9AA0A6),
|
||||||
accent = Color(0xFFE48A6E),
|
accent = Color(0xFFE48A6E),
|
||||||
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
|
chromeActive = Color(0xFFFFFFFF).copy(alpha = 0.16f),
|
||||||
separator = Color(0xFF2A2D31),
|
separator = Color(0xFF383B40),
|
||||||
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
borderCard = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||||
destructive = Color(0xFFE57368),
|
destructive = Color(0xFFE57368),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package dev.ulfrx.recipe.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
|
||||||
data object RecipeGlass {
|
data object RecipeGlass {
|
||||||
|
/** Strong refraction tuned for thin chrome elements (dock, pills, search bar). */
|
||||||
val menu: RecipeGlassStyle =
|
val menu: RecipeGlassStyle =
|
||||||
RecipeGlassStyle(
|
RecipeGlassStyle(
|
||||||
refraction = 0.10f,
|
refraction = 0.10f,
|
||||||
@@ -25,6 +27,18 @@ data object RecipeGlass {
|
|||||||
contrast = 1.0f,
|
contrast = 1.0f,
|
||||||
frost = 0.dp,
|
frost = 0.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Calm refraction with strong frost — for large surfaces where [menu] would read as a murky lens. */
|
||||||
|
val panel: RecipeGlassStyle =
|
||||||
|
RecipeGlassStyle(
|
||||||
|
refraction = 0.03f,
|
||||||
|
curve = 0.25f,
|
||||||
|
edge = 0.01f,
|
||||||
|
dispersion = 0.0f,
|
||||||
|
saturation = 0.5f,
|
||||||
|
contrast = 1.3f,
|
||||||
|
frost = 28.dp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RecipeGlassStyle(
|
data class RecipeGlassStyle(
|
||||||
@@ -36,3 +50,18 @@ data class RecipeGlassStyle(
|
|||||||
val contrast: Float,
|
val contrast: Float,
|
||||||
val frost: Dp,
|
val frost: Dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun lerp(
|
||||||
|
start: RecipeGlassStyle,
|
||||||
|
stop: RecipeGlassStyle,
|
||||||
|
fraction: Float,
|
||||||
|
): RecipeGlassStyle =
|
||||||
|
RecipeGlassStyle(
|
||||||
|
refraction = lerp(start.refraction, stop.refraction, fraction),
|
||||||
|
curve = lerp(start.curve, stop.curve, fraction),
|
||||||
|
edge = lerp(start.edge, stop.edge, fraction),
|
||||||
|
dispersion = lerp(start.dispersion, stop.dispersion, fraction),
|
||||||
|
saturation = lerp(start.saturation, stop.saturation, fraction),
|
||||||
|
contrast = lerp(start.contrast, stop.contrast, fraction),
|
||||||
|
frost = lerp(start.frost.value, stop.frost.value, fraction).dp,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user