package com.vgmlr.kiln
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
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 as DateTextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.ceil
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
private val CHART_PAD = PaddingValues(start = 6.dp, top = 28.dp, end = 22.dp, bottom = 12.dp)
private val CARD_GAP = 20.dp
private val CARD_HEIGHT = 220.dp
private val CARD_SHAPE = RoundedCornerShape(ROUND_END)
private val Y_GUTTER = 30.dp
private val X_GUTTER = 20.dp
private val MOOD_DAY_W = 12.dp
private val CYCLE_DAY_W = 3.dp
private val GridLine = Color.White.copy(alpha = 0.07f)
private val AxisLine = Color.White.copy(alpha = 0.20f)
private val LabelFg = Color.White.copy(alpha = 0.45f)
private val LABEL_STYLE = TextStyle(
color = LabelFg,
fontSize = 10.sp,
letterSpacing = 0.6.sp,
fontFamily = FontFamily.Monospace
)
private val CycleHue = KilnGradientPalette.colorFor(120f)
private val PreHue = KilnGradientPalette.colorFor(30f)
private val PeriodHue = KilnGradientPalette.colorFor(0f)
private val MoodHue = KilnGradientPalette.colorFor(60f)
private val MOOD_SCALE = Scale(-2, 1, 1)
@Composable
fun KilnAccountCharts(vm: KilnViewModel, onBack: () -> Unit) {
val records by vm.records.collectAsState()
val buttons by vm.buttons.collectAsState()
val account by vm.activeKilnAccount.collectAsState()
val roles = remember(buttons) { KilnButtonSet(buttons) }
val starts = remember(records, roles) { KilnCyclePredictor.periodStarts(records, roles) }
val cycle = remember(starts) { KilnCyclePredictor.avgCycleLength(starts) }
val hueOf = remember(records, roles, starts, cycle) {
KilnGradientPalette.lookup(records, roles, starts, cycle)
}
val cycles = remember(starts) { KilnCyclePredictor.cycleSeries(starts) }
val pre = remember(starts, cycle, hueOf) {
KilnDayStats.preMenstrualSeries(starts, cycle, hueOf)
}
val menses = remember(records, roles) { KilnCyclePredictor.periodRuns(records, roles) }
val mood = remember(records) { KilnDayStats.moodSeries(records) }
val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(Modifier.fillMaxSize().background(CBg)) {
Column(
Modifier.fillMaxSize()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = BAR_INSET_H)
) {
Spacer(Modifier.height(topInset + TOP_BAR_H + 8.dp))
ChartSection("Cycles", cycles, CycleHue, note = avgNote(cycles))
ChartSection("Pre-Menstruations", pre, PreHue, note = avgNote(pre))
ChartSection("Menstruations", menses, PeriodHue, note = avgNote(menses))
ChartSection(
"Moods",
mood,
MoodHue,
MOOD_SCALE,
moodNote(records),
dayW = MOOD_DAY_W
)
Spacer(Modifier.height(24.dp))
}
Row(
Modifier.fillMaxWidth()
.padding(top = topInset)
.height(TOP_BAR_H)
.padding(horizontal = BAR_INSET_H),
horizontalArrangement = Arrangement.spacedBy(BAR_GAP),
verticalAlignment = Alignment.CenterVertically
) {
KilnBarButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onClick = onBack)
KilnBlankButton(
Modifier.weight(1f),
account?.name.orEmpty(),
Alignment.CenterStart
)
}
}
}
@Composable
private fun ChartSection(
title: String,
points: List<Pair<LocalDate, Int>>,
color: Color,
scale: Scale? = null,
note: String? = null,
dayW: Dp = CYCLE_DAY_W
) {
WgSectionLabel(if (note == null) title else "$title - $note")
WgCard(Modifier.height(CARD_HEIGHT).border(EDGE, CARD_SHAPE)) {
ScrollChart(points, color, dayW, Modifier.fillMaxSize(), scale)
}
Spacer(Modifier.height(CARD_GAP))
}
private fun avgNote(points: List<Pair<LocalDate, Int>>): String? =
points.takeIf { it.isNotEmpty() }
?.let { avgText(it.map { p -> p.second }.average().toFloat()) }
private fun moodNote(records: List<KilnDayRecord>): String? {
val (positive, total) = KilnDayStats.moodBalance(records)
return moodBalanceText(positive, total)
}
@Composable
private fun NoData(modifier: Modifier) {
Box(modifier.padding(CHART_PAD), contentAlignment = Alignment.Center) {
Text("NO DATA", color = CMuted, fontSize = 11.sp, letterSpacing = 1.4.sp)
}
}
@Composable
private fun ScrollChart(
points: List<Pair<LocalDate, Int>>,
color: Color,
dayW: Dp,
modifier: Modifier = Modifier,
fixed: Scale? = null
) {
if (points.isEmpty()) {
NoData(modifier)
return
}
val pts = remember(points) { points.sortedBy { it.first } }
val scale = remember(pts, fixed) { fixed ?: scaleFor(pts.map { it.second }) }
val first = pts.first().first
val last = pts.last().first
val minX = first.toEpochDay()
val days = (last.toEpochDay() - minX + 1).toInt()
val ticks = remember(pts) { monthTicks(first, last) }
val measurer = rememberTextMeasurer()
val scroll = rememberScrollState()
var pinned by remember(days) { mutableStateOf(false) }
LaunchedEffect(scroll.maxValue, pinned) {
if (pinned || scroll.maxValue == Int.MAX_VALUE) return@LaunchedEffect
scroll.scrollTo(scroll.maxValue)
pinned = true
}
Row(modifier.padding(CHART_PAD)) {
Canvas(Modifier.width(Y_GUTTER).fillMaxHeight()) {
val bottom = size.height - X_GUTTER.toPx()
val hair = 1.dp.toPx()
var t = scale.min
while (t <= scale.max) {
val lay = measurer.measure(AnnotatedString("$t"), LABEL_STYLE)
drawText(
lay,
topLeft = Offset(
size.width - 6.dp.toPx() - lay.size.width,
yAt(t, scale, bottom) - lay.size.height / 2f
)
)
t += scale.step
}
val ax = size.width - hair / 2f
drawLine(AxisLine, Offset(ax, 0f), Offset(ax, bottom), hair)
}
Canvas(
Modifier.weight(1f)
.fillMaxHeight()
.horizontalScroll(scroll)
.width(dayW * days)
) {
val bottom = size.height - X_GUTTER.toPx()
val hair = 1.dp.toPx()
val dayPx = size.width / days
fun xOf(d: LocalDate) = (d.toEpochDay() - minX + 0.5f) * dayPx
fun yOf(v: Int) = yAt(v, scale, bottom)
var t = scale.min
while (t <= scale.max) {
val y = yOf(t)
val rule = if (t == 0 && scale.min < 0) AxisLine else GridLine
drawLine(rule, Offset(0f, y), Offset(size.width, y), hair)
t += scale.step
}
for (m in ticks) {
val x = xOf(m.atDay(1)).coerceAtLeast(0f)
if (x > 0f) drawLine(GridLine, Offset(x, 0f), Offset(x, bottom), hair)
val lay = measurer.measure(AnnotatedString(monthLabel(m)), LABEL_STYLE)
val lx = x.coerceAtMost((size.width - lay.size.width).coerceAtLeast(0f))
drawText(lay, topLeft = Offset(lx, bottom + 5.dp.toPx()))
}
drawLine(AxisLine, Offset(0f, bottom), Offset(size.width, bottom), hair)
val path = Path()
pts.forEachIndexed { i, (d, v) ->
val x = xOf(d)
val y = yOf(v)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(
path,
color,
style = Stroke(2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)
)
pts.forEach { (d, v) -> drawCircle(color, 3.dp.toPx(), Offset(xOf(d), yOf(v))) }
}
}
}
private data class Scale(val min: Int, val max: Int, val step: Int)
private fun yAt(v: Int, s: Scale, bottom: Float): Float =
bottom - bottom * ((v - s.min) / (s.max - s.min).toFloat())
private fun scaleFor(values: List<Int>): Scale {
val lo = (values.min() - 1).coerceAtLeast(0)
val span = (values.max() + 1 - lo).coerceAtLeast(2)
val step = ceil(span / 4f).toInt().coerceAtLeast(1)
return Scale(lo, lo + step * ceil(span / step.toFloat()).toInt(), step)
}
private fun monthTicks(first: LocalDate, last: LocalDate): List<YearMonth> {
val a = YearMonth.from(first)
val b = YearMonth.from(last)
val n = ChronoUnit.MONTHS.between(a, b).toInt() + 1
return (0 until n).map { a.plusMonths(it.toLong()) }
}
private fun monthLabel(m: YearMonth): String {
val name = m.month.getDisplayName(DateTextStyle.SHORT, Locale.getDefault())
.uppercase(Locale.getDefault()).take(3)
return if (m.monthValue == 1) "$name ${m.year % 100}" else name
}