package com.vgmlr.kiln
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.outlined.Login
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.outlined.Park
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
import androidx.compose.ui.unit.Dp
import kotlin.math.abs
private const val PAGE_COUNT = 500
private val EPOCH_MONTH = YearMonth.of(2000, 1)
private val ROW_HEIGHT = 44.dp
private val CELL_GAP = 0.dp
private val DATA_DOT = 5.dp
private val DATA_DOT_PAD = 6.dp
private val GRID_TOP_GAP = 6.dp
private val NAV_BUTTON = 32.dp
private val NAV_ICON = 20.dp
internal val ROUND_END = 6.dp
private val RAISED_SHAPE = RoundedCornerShape(ROUND_END)
private const val SELECTED_SCALE = 1.15f
private val RAISED_BORDER_W = 1.dp
private val RaisedBorder = CBg
private val LIFT_SPEC = tween<Float>(120, easing = FastOutSlowInEasing)
private val CARD_PAD_H = 18.dp
private val CARD_PAD_V = 14.dp
private val SECTION_GAP = 12.dp
private val HEADER_GAP = 0.dp
private val STAT_GAP = 5.dp
private val CARD_CORNER = ROUND_END
private val STAT_CARD_SHAPE = RoundedCornerShape(CARD_CORNER)
private val STAT_CARD_PAD_H = 16.dp
private val STAT_CARD_PAD_V = 12.dp
private val STAT_TRACKING = 0.8.sp
private val DATE_NUM = 34.sp
private val META_GAP = 10.dp
private val RULE_H = 30.dp
internal val TopBarBg = Color(0xFF486860)
internal val TopBarFg = Color.White
private val CardBg = Color(0xFF16181B)
private val StatCardBg = Color(0xFF1F2226)
private const val GRID_COLS = 7
private const val GRID_ROWS = 6
private fun pageToMonth(page: Int): YearMonth =
EPOCH_MONTH.plusMonths((page - PAGE_COUNT / 2).toLong())
.plusMonths(YearMonth.now().let {
ChronoUnit.MONTHS.between(EPOCH_MONTH, it)
})
private fun monthToPage(month: YearMonth): Int =
(PAGE_COUNT / 2 + ChronoUnit.MONTHS.between(YearMonth.now(), month))
.toInt().coerceIn(0, PAGE_COUNT - 1)
private val MOOD_FACE_SIZE = 74.dp
private val MOOD_FACE_BOX = MOOD_FACE_SIZE + 0.dp
private sealed interface Screen {
data object Home : Screen
data object HomeSettings : Screen
data object Calendar : Screen
data object AccountSettings : Screen
data object AccountCharts : Screen
}
private val Screen.parent: Screen?
get() = when (this) {
Screen.Home -> null
Screen.HomeSettings, Screen.Calendar -> Screen.Home
Screen.AccountSettings, Screen.AccountCharts -> Screen.Calendar
}
@Composable
fun KilnApp(vm: KilnViewModel) {
var screen by remember { mutableStateOf<Screen>(Screen.Home) }
val summaries by vm.summaries.collectAsState()
val account by vm.activeKilnAccount.collectAsState()
val accounts by vm.accounts.collectAsState()
val defaultId by vm.defaultAccountId.collectAsState()
val loadDefault by vm.loadDefaultOnStart.collectAsState()
var autoLoaded by remember { mutableStateOf(false) }
LaunchedEffect(loadDefault, defaultId, accounts) {
if (autoLoaded || !loadDefault) return@LaunchedEffect
val target = accounts.firstOrNull { it.id == defaultId } ?: return@LaunchedEffect
autoLoaded = true
vm.open(target)
screen = Screen.Calendar
}
val parent = screen.parent
BackHandler(enabled = parent != null) { parent?.let { screen = it } }
when (screen) {
Screen.Home -> HomeScreen(
summaries,
onOpen = { vm.open(it); screen = Screen.Calendar },
onAdd = { vm.addAccount { screen = Screen.Calendar } },
onDelete = vm::deleteAccount,
onSettings = { screen = Screen.HomeSettings },
onImport = vm::importFrom
)
Screen.HomeSettings -> KilnHomeSettings(vm, onBack = { screen = Screen.Home })
Screen.Calendar -> MainScreen(
vm,
hair = KilnHair.of(account?.hair),
onBack = { screen = Screen.Home },
onSettings = { screen = Screen.AccountSettings },
onCharts = { screen = Screen.AccountCharts }
)
Screen.AccountSettings -> KilnAccountSettings(
vm,
onBack = { screen = Screen.Calendar },
onDeleted = { screen = Screen.Home }
)
Screen.AccountCharts -> KilnAccountCharts(vm, onBack = { screen = Screen.Calendar })
}
}
@Composable
internal fun KilnTopBar(
title: String = "",
onSettings: () -> Unit,
onTitle: ((String) -> Unit)? = null,
onBack: (() -> Unit)? = null,
onVersion: (() -> Unit)? = null,
onCharts: (() -> Unit)? = null,
onImport: (() -> Unit)? = null,
onExport: (() -> Unit)? = null,
showExit: Boolean = false,
action: @Composable () -> Unit = {}
) {
var menuExpanded by remember { mutableStateOf(false) }
val items = buildList {
fun item(icon: ImageVector, desc: String, run: (() -> Unit)?, plus: Dp = 0.dp) {
if (run != null) add(KilnDeployItem(icon, desc, plus) { menuExpanded = false; run() })
}
item(Icons.AutoMirrored.Outlined.Login, "Import", onImport)
item(Icons.Outlined.Save, "Export", onExport, 2.dp)
item(Icons.Outlined.Settings, "Settings", onSettings)
item(Icons.Outlined.Park, "Version", onVersion)
if (showExit) add(KilnDeployItem(Icons.Filled.Block, "Exit", enabled = false) {})
}
Row(
Modifier.fillMaxWidth()
.statusBarsPadding()
.height(TOP_BAR_H)
.padding(horizontal = BAR_INSET_H),
horizontalArrangement = Arrangement.spacedBy(BAR_GAP),
verticalAlignment = Alignment.CenterVertically
) {
if (onBack != null) {
KilnBarButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onClick = onBack)
}
if (onTitle != null) KilnBlankField(title, onTitle, Modifier.weight(1f))
else KilnBlankButton(Modifier.weight(1f), title)
if (onCharts != null) {
KilnBarButton(Icons.Filled.BarChart, "Graphs", onClick = onCharts)
}
action()
Box {
KilnBarButton(Icons.Outlined.WaterDrop, "Menu") { menuExpanded = true }
KilnDeploy(
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
items = items
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
vm: KilnViewModel,
hair: KilnHair,
onBack: () -> Unit,
onSettings: () -> Unit,
onCharts: () -> Unit
) {
val records by vm.records.collectAsState()
val account by vm.activeKilnAccount.collectAsState()
var name by remember(account?.id) { mutableStateOf(account?.name.orEmpty()) }
val buttons by vm.buttons.collectAsState()
val selected by vm.selectedDate.collectAsState()
val record by vm.selectedRecord.collectAsState()
val today = remember { LocalDate.now() }
var celebration by remember { mutableIntStateOf(0) }
var pendingSelect by remember { mutableStateOf<LocalDate?>(null) }
val exporter = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(KilnTransfer.MIME)
) { uri -> uri?.let(vm::exportActive) }
val roles = remember(buttons) { KilnButtonSet(buttons) }
val starts = remember(records, roles) { KilnCyclePredictor.periodStarts(records, roles) }
val cycle = remember(starts) { KilnCyclePredictor.avgCycleLength(starts) }
val hueOf: (LocalDate) -> Float = remember(records, roles, starts, cycle, today) {
KilnGradientPalette.lookup(records, roles, starts, cycle, today)
}
val faces = remember(hair) { KilnFaces.faces(hair) }
val dataDays = remember(records) {
records.filter { it.hasData }.mapTo(HashSet()) { LocalDate.ofEpochDay(it.epochDay) }
}
val pager = rememberPagerState(initialPage = PAGE_COUNT / 2) { PAGE_COUNT }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val shareLayer = rememberGraphicsLayer()
fun selectIn(month: YearMonth) {
val day = vm.selectedDate.value.dayOfMonth
vm.select(month.atDay(minOf(day, month.lengthOfMonth())))
}
fun selectDay(date: LocalDate) {
val page = monthToPage(YearMonth.from(date))
if (page == pager.currentPage) {
vm.select(date)
return
}
pendingSelect = date
scope.launch { pager.animateScrollToPage(page) }
}
val preAvg = remember(hueOf) { KilnDayStats.preMenstrualAvg(starts, cycle, hueOf) }
val preLast = remember(hueOf) {
KilnDayStats.preMenstrualSeries(starts, cycle, hueOf).lastOrNull()?.second
}
val stats = remember(selected, today, roles, hueOf, preAvg, preLast) {
KilnDayStats.of(selected, today, records, roles, starts, cycle, hueOf, preAvg, preLast)
}
fun goMonth(delta: Int) {
val page = (pager.currentPage + delta).coerceIn(0, PAGE_COUNT - 1)
selectIn(pageToMonth(page))
scope.launch { pager.animateScrollToPage(page) }
}
LaunchedEffect(pager) {
snapshotFlow { pager.settledPage }.collect { page ->
val month = pageToMonth(page)
val pending = pendingSelect
pendingSelect = null
if (pending != null && YearMonth.from(pending) == month) vm.select(pending)
else selectIn(month)
}
}
Scaffold(
modifier = Modifier.drawWithContent {
shareLayer.record { this@drawWithContent.drawContent() }
drawLayer(shareLayer)
},
containerColor = CardBg,
topBar = {
KilnTopBar(
name,
onTitle = { name = it; vm.setName(it) },
onSettings = onSettings,
onBack = onBack,
onCharts = onCharts,
onExport = { exporter.launch(KilnTransfer.fileName()) },
action = {
KilnBarButton(Icons.Outlined.Share, "Share") {
scope.launch { KilnShare.send(context, shareLayer.toImageBitmap()) }
}
}
)
}) { pad ->
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().background(CardBg).padding(pad)) {
HorizontalPager(state = pager) { page ->
MonthGrid(
month = pageToMonth(page),
selected = selected,
hueOf = hueOf,
dataDays = dataDays,
onSelect = { selectDay(it) }
)
}
DayDetail(
selected, today, record, stats, faces, buttons, vm::edit,
onToggle = { id ->
if (opensPeriod(id, buttons, roles, record)) {
celebration = System.nanoTime().toInt() or 1
}
vm.toggle(id)
},
onAddButton = onSettings,
onPrevMonth = { goMonth(-1) },
onNextMonth = { goMonth(1) },
onToday = { selectDay(today) },
onCharts = onCharts,
modifier = Modifier.weight(1f)
)
}
KilnCelebration(celebration) { celebration = 0 }
}
}
}
private fun opensPeriod(
id: Long,
buttons: List<KilnButton>,
roles: KilnButtonSet,
record: KilnDayRecord?
): Boolean {
if (buttons.firstOrNull { it.id == id }?.kindEnum != KilnButtonKind.PERIOD) return false
if (record == null) return true
return id !in record.checked && !roles.isPeriodSighting(record)
}
@Composable
private fun MonthGrid(
month: YearMonth,
selected: LocalDate,
hueOf: (LocalDate) -> Float,
dataDays: Set<LocalDate>,
onSelect: (LocalDate) -> Unit
) {
val today = LocalDate.now()
val firstOffset = month.atDay(1).dayOfWeek.value % 7
val first = month.atDay(1).minusDays(firstOffset.toLong())
val cells = (0 until 42L).map(first::plusDays)
val edges = remember(month, hueOf) {
(-1L..42L).map { hueOf(first.plusDays(it)) }
.zipWithNext { a, b -> (a + b) / 2f }
}
Column(Modifier.fillMaxWidth().padding(horizontal = BAR_INSET_H)) {
Spacer(Modifier.height(GRID_TOP_GAP))
cells.chunked(GRID_COLS).forEachIndexed { row, week ->
val z = if (week.any { it == selected && YearMonth.from(it) == month }) 1f else 0f
Row(Modifier.fillMaxWidth().height(ROW_HEIGHT).zIndex(z)) {
week.forEachIndexed { col, date ->
val i = row * GRID_COLS + col
DayCell(date, YearMonth.from(date) == month, selected, today,
edges[i], edges[i + 1], date in dataDays, cellShape(row, col),
onSelect, Modifier.weight(1f))
}
}
}
}
}
private fun cellShape(row: Int, col: Int): Shape {
val top = row == 0
val bottom = row == GRID_ROWS - 1
val start = col == 0
val end = col == GRID_COLS - 1
return RoundedCornerShape(
topStart = if (top && start) CARD_CORNER else 0.dp,
topEnd = if (top && end) CARD_CORNER else 0.dp,
bottomStart = if (bottom && start) CARD_CORNER else 0.dp,
bottomEnd = if (bottom && end) CARD_CORNER else 0.dp
)
}
@Composable
private fun DayCell(
date: LocalDate,
inMonth: Boolean,
selected: LocalDate,
today: LocalDate,
hueStart: Float,
hueEnd: Float,
hasData: Boolean,
shape: Shape,
onSelect: (LocalDate) -> Unit,
modifier: Modifier
) {
val alpha = if (inMonth) 1f else 0.35f
val strip = Brush.horizontalGradient(
listOf(KilnGradientPalette.colorFor(hueStart), KilnGradientPalette.colorFor(hueEnd))
)
val isToday = date == today && inMonth
val isSelected = date == selected && inMonth
val k by animateFloatAsState(
if (isSelected) SELECTED_SCALE else 1f,
animationSpec = if (inMonth) LIFT_SPEC else snap(),
label = "cellLift"
)
val lifted = k > 1f
val face = if (lifted) RAISED_SHAPE else shape
Box(
modifier.fillMaxHeight()
.zIndex(if (lifted) 1f else 0f)
.padding(CELL_GAP)
.scale(k)
.clip(face)
.background(strip, alpha = alpha)
.then(
if (lifted) Modifier.border(RAISED_BORDER_W, RaisedBorder, face) else Modifier
)
.clickable { onSelect(date) },
contentAlignment = Alignment.Center
) {
Text("${date.dayOfMonth}", fontSize = 15.sp,
color = Color(0xFF111111).copy(alpha = if (inMonth) 0.9f else 0.6f),
fontWeight = if (isToday || isSelected) FontWeight.Bold
else FontWeight.Medium)
if (hasData) Box(
Modifier.align(Alignment.BottomEnd)
.padding(DATA_DOT_PAD)
.size(DATA_DOT)
.background(
Color.Black.copy(alpha = if (inMonth) 0.8f else 0.35f),
CircleShape
)
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DayDetail(
date: LocalDate,
today: LocalDate,
record: KilnDayRecord?,
stats: KilnDayStats,
faces: List<Int>,
buttons: List<KilnButton>,
edit: ((KilnDayRecord) -> KilnDayRecord) -> Unit,
onToggle: (Long) -> Unit,
onAddButton: () -> Unit,
onPrevMonth: () -> Unit,
onNextMonth: () -> Unit,
onToday: () -> Unit,
onCharts: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier.fillMaxWidth(),
shape = RectangleShape,
color = CardBg
) {
Column(Modifier.fillMaxSize().padding(vertical = CARD_PAD_V)) {
DateHeader(
date, onPrevMonth, onNextMonth, onToday,
Modifier.padding(horizontal = CARD_PAD_H)
)
Spacer(Modifier.height(HEADER_GAP))
Column(
Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(SECTION_GAP, Alignment.CenterVertically)
) {
MoodRow(record?.mood, faces, Modifier.padding(horizontal = CARD_PAD_H)) { i ->
edit { it.copy(mood = if (it.mood == i) null else i) }
}
ChipPager(buttons, record?.checked.orEmpty(), onToggle, onAddButton)
}
Spacer(Modifier.height(SECTION_GAP))
StatBlock(today, stats, onCharts, Modifier.padding(horizontal = BAR_INSET_H))
}
}
}
@Composable
private fun DateHeader(
date: LocalDate,
onPrev: () -> Unit,
onNext: () -> Unit,
onToday: () -> Unit,
modifier: Modifier = Modifier
) {
val weekday = date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault())
.uppercase(Locale.getDefault())
val month = date.month.getDisplayName(TextStyle.FULL, Locale.getDefault())
.uppercase(Locale.getDefault())
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
NavButton(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Previous month", onPrev)
Row(
Modifier.weight(1f).clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onToday
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"${date.dayOfMonth}",
fontSize = DATE_NUM,
lineHeight = DATE_NUM,
letterSpacing = (-1).sp,
fontWeight = FontWeight.Light,
color = Color.White.copy(alpha = 0.9f)
)
Spacer(Modifier.width(META_GAP))
Box(
Modifier.height(RULE_H).width(1.dp)
.background(Color.White.copy(alpha = 0.12f))
)
Spacer(Modifier.width(META_GAP))
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
weekday,
fontSize = 11.sp,
lineHeight = 11.sp,
letterSpacing = 2.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.45f)
)
Text(
"$month ${date.year.toString().takeLast(3)}",
fontSize = 13.sp,
lineHeight = 13.sp,
letterSpacing = 1.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.88f)
)
}
}
NavButton(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Next month", onNext)
}
}
@Composable
private fun StatBlock(
today: LocalDate,
stats: KilnDayStats,
onCharts: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier.fillMaxWidth()
.clip(STAT_CARD_SHAPE)
.background(StatCardBg)
.border(EDGE, STAT_CARD_SHAPE)
.padding(horizontal = STAT_CARD_PAD_H, vertical = STAT_CARD_PAD_V),
verticalArrangement = Arrangement.spacedBy(STAT_GAP)
) {
StatRow("NEXT PRE-MENSTRUATION", preText(today, stats.daysToPreMenstrual))
StatRow("NEXT MENSTRUATION", dueText(today, stats.daysToPeriod))
StatRow("AVERAGE CYCLE", deltaText(stats.intermissionAvg, stats.intermissionLast), onCharts)
StatRow(
"AVERAGE PRE-MENSTRUATION",
deltaText(stats.preMenstrualAvg, stats.preMenstrualLast),
onCharts
)
StatRow(
"AVERAGE MENSTRUATION",
deltaText(stats.menstruationAvg, stats.menstruationLast),
onCharts
)
StatRow("MOOD RATIO", moodText(stats), onCharts)
}
}
@Composable
private fun StatRow(label: String, value: String, onOpen: (() -> Unit)? = null) {
Row(
Modifier.fillMaxWidth().then(
if (onOpen == null) Modifier else Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onOpen
)
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
label,
fontSize = 11.sp,
letterSpacing = 1.4.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.8f)
)
}
Text(
value,
fontSize = 13.sp,
letterSpacing = STAT_TRACKING,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.9f)
)
}
}
private fun daysText(days: Long?): String = when (days) {
null -> "—"
0L -> "TODAY"
1L -> "1 DAY"
else -> "$days DAYS"
}
private fun preText(today: LocalDate, days: Long?): String = when {
days == null -> "—"
days == 0L -> "NOW"
days < 0L -> "NOW (+${-days})"
else -> daysText(days) + dateText(today, days)
}
private fun dueText(today: LocalDate, days: Long?): String = when {
days == null -> "—"
days == 0L -> "TODAY"
days < 0L -> daysText(-days) + " LATE"
else -> daysText(days) + dateText(today, days)
}
internal fun avgText(days: Float?): String {
if (days == null) return "—"
val r = (days * 10f).roundToInt() / 10f
val n = if (r % 1f == 0f) r.toInt().toString() else r.toString()
return "$n ${if (r == 1f) "DAY" else "DAYS"}"
}
private fun deltaText(avg: Float?, last: Int?): String {
if (avg == null || last == null) return avgText(avg)
val d = ((last - (avg * 10f).roundToInt() / 10f) * 10f).roundToInt() / 10f
if (d == 0f) return avgText(avg)
val n = abs(d).let { if (it % 1f == 0f) it.toInt().toString() else it.toString() }
return "${avgText(avg)} (${if (d > 0f) "+" else "-"}$n)"
}
private fun dateText(date: LocalDate, days: Long?): String {
val target = date.plusDays(days ?: return "")
val month = target.month
.getDisplayName(TextStyle.SHORT, Locale.getDefault())
.uppercase(Locale.getDefault())
.take(3)
return " (${target.dayOfMonth} $month)"
}
private fun moodText(stats: KilnDayStats): String =
moodBalanceText(stats.moodPositive, stats.moodDays)?.uppercase(Locale.getDefault()) ?: "—"
@Composable
private fun NavButton(icon: ImageVector, desc: String, onClick: () -> Unit) {
IconButton(
onClick,
Modifier.size(NAV_BUTTON).clip(CircleShape).background(Color.White.copy(alpha = 0.0f))
) {
Icon(icon, desc, Modifier.size(NAV_ICON), tint = Color.White.copy(alpha = 0.55f))
}
}
@Composable
private fun MoodRow(
mood: Int?,
faces: List<Int>,
modifier: Modifier = Modifier,
onPick: (Int) -> Unit
) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
faces.forEachIndexed { i, faceRes ->
val k by animateFloatAsState(if (mood == i) 1f else 0f, label = "mood$i")
Box(
Modifier.size(MOOD_FACE_BOX)
.scale(0.80f + 0.20f * k)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.10f * k))
.border(1.5.dp, Color.White.copy(alpha = 0.2f * k), CircleShape)
.clickable { onPick(i) },
contentAlignment = Alignment.Center
) {
Image(
painterResource(faceRes),
KilnFaces.LABELS[i],
Modifier.size(MOOD_FACE_SIZE),
alpha = 0.80f + 0.20f * k
)
}
}
}
}