package com.vgmlr.kiln
import androidx.compose.animation.core.animateFloatAsState
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.filled.BarChart
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Share
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.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.material.icons.automirrored.outlined.Login
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
private const val PAGE_COUNT = 1000
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 = 0.dp
private val NAV_BUTTON = 32.dp
private val NAV_ICON = 20.dp
private val TodayBorder = Color(0xFFFFFFFF)
private val TodayBorderWidth = 1.dp
private val TodayBorderShape = RoundedCornerShape(10.dp)
private val SelectedBorder = Color(0xFFFFFFFF)
private val SelectedBorderWidth = 2.dp
private val CARD_PAD_H = 18.dp
private val CARD_PAD_V = 14.dp
private val STAT_CARD_INSET_H = 9.dp
private val SECTION_GAP = 12.dp
private val HEADER_GAP = 0.dp
private val STAT_GAP = 5.dp
private val STAT_CARD_SHAPE = RoundedCornerShape(12.dp)
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 STAT_ICON = 13.dp
private val STAT_ICON_GAP = 7.dp
private val STAT_ICON_NUDGE = (-1.5).dp
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
internal val MenuBg = Color(0xFF272A30)
private val CardBg = Color(0xFF16181B)
private val StatCardBg = Color(0xFF1F2226)
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 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,
onSettings = { screen = Screen.HomeSettings },
onImport = vm::importFrom
)
Screen.HomeSettings -> KilnHomeSettings(vm, onBack = { screen = Screen.Home })
Screen.Calendar -> MainScreen(
vm,
accountName = account?.name.orEmpty(),
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 })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun KilnTopBar(
title: String,
onSettings: () -> Unit,
onBack: (() -> Unit)? = null,
onVersion: (() -> Unit)? = null,
onCharts: (() -> Unit)? = null,
onImport: (() -> Unit)? = null,
onExport: (() -> Unit)? = null,
action: @Composable () -> Unit = {}
) {
var menuExpanded by remember { mutableStateOf(false) }
TopAppBar(
title = { },
colors = TopAppBarDefaults.topAppBarColors(containerColor = TopBarBg),
navigationIcon = {
if (onBack != null) IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = TopBarFg)
}
},
actions = {
Row(
modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
title,
Modifier.weight(1f),
color = TopBarFg,
fontSize = 18.sp,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(10.dp))
action()
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, null, tint = TopBarFg)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(MenuBg).padding(horizontal = 6.dp)
) {
if (onImport != null) DropdownMenuItem(
text = { Text("Import", color = TopBarFg) },
onClick = { menuExpanded = false; onImport() },
leadingIcon = {
Icon(Icons.AutoMirrored.Outlined.Login, null, tint = TopBarFg)
}
)
if (onExport != null) DropdownMenuItem(
text = { Text("Export", color = TopBarFg) },
onClick = { menuExpanded = false; onExport() },
leadingIcon = {
Icon(Icons.AutoMirrored.Outlined.Logout, null, tint = TopBarFg)
}
)
if (onCharts != null) DropdownMenuItem(
text = { Text("Charts", color = TopBarFg) },
onClick = { menuExpanded = false; onCharts() },
leadingIcon = { Icon(Icons.Filled.BarChart, null, tint = TopBarFg) }
)
DropdownMenuItem(
text = { Text("Settings", color = TopBarFg) },
onClick = { menuExpanded = false; onSettings() },
leadingIcon = { Icon(Icons.Outlined.Settings, null, tint = TopBarFg) }
)
if (onVersion != null) DropdownMenuItem(
text = { Text("Version", color = TopBarFg) },
onClick = { menuExpanded = false; onVersion() },
leadingIcon = { Icon(Icons.Outlined.History, null, tint = TopBarFg) }
)
}
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
vm: KilnViewModel,
accountName: String,
hair: KilnHair,
onBack: () -> Unit,
onSettings: () -> Unit,
onCharts: () -> Unit
) {
val records by vm.records.collectAsState()
val buttons by vm.buttons.collectAsState()
val selected by vm.selectedDate.collectAsState()
val record by vm.selectedRecord.collectAsState()
val today = remember { LocalDate.now() }
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) {
KilnGradientPalette.lookup(records, roles, starts, cycle)
}
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()
fun selectIn(month: YearMonth) {
val day = vm.selectedDate.value.dayOfMonth
vm.select(month.atDay(minOf(day, month.lengthOfMonth())))
}
val preAvg = remember(hueOf) { KilnDayStats.preMenstrualAvg(starts, cycle, hueOf) }
val stats = remember(selected, today, roles, hueOf, preAvg) {
KilnDayStats.of(selected, today, records, roles, starts, cycle, hueOf, preAvg)
}
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 { selectIn(pageToMonth(it)) }
}
Scaffold(containerColor = CardBg, topBar = {
KilnTopBar(
accountName,
onSettings = onSettings,
onBack = onBack,
onCharts = onCharts,
onExport = { exporter.launch(KilnTransfer.fileName()) },
action = {
IconButton(onClick = { }, enabled = false) {
Icon(Icons.Outlined.Share, "Share", tint = TopBarFg.copy(alpha = 0.3f))
}
}
)
}) { pad ->
Column(Modifier.fillMaxSize().background(CardBg).padding(pad)) {
HorizontalPager(state = pager) { page ->
MonthGrid(
month = pageToMonth(page),
selected = selected,
hueOf = hueOf,
dataDays = dataDays,
onSelect = vm::select
)
}
DayDetail(
selected, today, record, stats, faces, buttons, vm::edit,
onToggle = vm::toggle,
onAddButton = onSettings,
onPrevMonth = { goMonth(-1) },
onNextMonth = { goMonth(1) },
onCharts = onCharts,
modifier = Modifier.weight(1f)
)
}
}
}
@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()) {
Spacer(Modifier.height(GRID_TOP_GAP))
cells.chunked(7).forEachIndexed { row, week ->
Row(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
week.forEachIndexed { col, date ->
val i = row * 7 + col
DayCell(date, YearMonth.from(date) == month, selected, today,
edges[i], edges[i + 1], date in dataDays, onSelect, Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun DayCell(
date: LocalDate,
inMonth: Boolean,
selected: LocalDate,
today: LocalDate,
hueStart: Float,
hueEnd: Float,
hasData: Boolean,
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 border = when (date) {
selected -> Modifier.border(SelectedBorderWidth, SelectedBorder, TodayBorderShape)
today -> Modifier.border(TodayBorderWidth, TodayBorder, TodayBorderShape)
else -> Modifier
}
Box(
modifier.fillMaxHeight()
.padding(CELL_GAP)
.background(strip, alpha = alpha)
.then(border)
.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 (date == today || date == selected) 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,
onCharts: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier.fillMaxWidth(),
shape = RectangleShape,
color = CardBg
) {
Column(Modifier.fillMaxSize().padding(vertical = CARD_PAD_V)) {
DateHeader(
date, onPrevMonth, onNextMonth,
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 = STAT_CARD_INSET_H))
}
}
}
@Composable
private fun DateHeader(
date: LocalDate,
onPrev: () -> Unit,
onNext: () -> 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),
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)
.padding(horizontal = STAT_CARD_PAD_H, vertical = STAT_CARD_PAD_V),
verticalArrangement = Arrangement.spacedBy(STAT_GAP)
) {
StatRow("NEXT PRE-MENSTRUATION",
daysText(stats.daysToPreMenstrual) + dateText(today, stats.daysToPreMenstrual)
)
StatRow("NEXT MENSTRUATION",
daysText(stats.daysToPeriod) + dateText(today, stats.daysToPeriod)
)
StatRow("AVERAGE CYCLE", avgText(stats.intermissionAvg), onCharts)
StatRow("AVERAGE PRE-MENSTRUATION", avgText(stats.preMenstrualAvg), onCharts)
StatRow("AVERAGE MENSTRUATION", avgText(stats.menstruationAvg), onCharts)
StatRow("MOOD RATIO", moodText(stats))
}
}
@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.40f)
)
if (onOpen != null) {
Spacer(Modifier.width(STAT_ICON_GAP))
Icon(
Icons.Filled.BarChart,
"Open Charts",
Modifier.size(STAT_ICON).offset(y = STAT_ICON_NUDGE),
tint = Color.White.copy(alpha = 0.45f)
)
}
}
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 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 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 {
val top = stats.topMood ?: return "—"
return moodPart(stats.topMoodCount, stats.moodDays, top)
}
private fun moodPart(count: Int, days: Int, mood: Int): String {
val pct = (count * 100f / days).roundToInt()
return "$pct% ${KilnFaces.LABELS[mood].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
)
}
}
}
}