diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0752fa..c96c69e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) + implementation(libs.compose.ui.text.google.fonts) implementation(libs.lifecycle.runtime.ktx) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.runtime.compose) diff --git a/app/src/main/java/com/hsdiary/data/repository/CycleRepository.kt b/app/src/main/java/com/hsdiary/data/repository/CycleRepository.kt index 725e6e2..a4df609 100644 --- a/app/src/main/java/com/hsdiary/data/repository/CycleRepository.kt +++ b/app/src/main/java/com/hsdiary/data/repository/CycleRepository.kt @@ -38,12 +38,11 @@ class CycleRepository @Inject constructor(private val dao: CycleRecordDao) { } suspend fun endCurrentCycle(profileId: Long, endDate: String) { + // Only sets the menstruation end. Full cycle_length is set by startNewCycle() + // when the next cycle begins (start-to-next-start), not by the period end. + // Period length is derivable on demand from (cycle_end - cycle_start + 1). val current = dao.getCurrentCycleRecord(profileId) ?: return - val length = java.time.LocalDate.parse(endDate) - .toEpochDay() - .minus(java.time.LocalDate.parse(current.cycleStart).toEpochDay()) - .toInt() + 1 - dao.updateCycleRecord(current.copy(cycleEnd = endDate, cycleLength = length)) + dao.updateCycleRecord(current.copy(cycleEnd = endDate)) } suspend fun removePeriodStart(profileId: Long, date: String) { diff --git a/app/src/main/java/com/hsdiary/domain/CyclePredictionEngine.kt b/app/src/main/java/com/hsdiary/domain/CyclePredictionEngine.kt index b749811..bc530b6 100644 --- a/app/src/main/java/com/hsdiary/domain/CyclePredictionEngine.kt +++ b/app/src/main/java/com/hsdiary/domain/CyclePredictionEngine.kt @@ -21,8 +21,19 @@ class CyclePredictionEngine @Inject constructor() { if (records.isEmpty()) return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd) val completedLengths = records.mapNotNull { it.cycleLength } - val avgLength = if (completedLengths.isEmpty()) defaultCycleLength - else completedLengths.takeLast(12).average().roundToInt() + val avgCycleLength = if (completedLengths.isEmpty()) maxOf(14, defaultCycleLength) + else maxOf(14, completedLengths.takeLast(12).average().roundToInt()) + + // Derive average period length from confirmed start/end pairs; default 5 + val periodLengths = records.mapNotNull { rec -> + rec.cycleEnd?.let { endStr -> + (LocalDate.parse(endStr).toEpochDay() - + LocalDate.parse(rec.cycleStart).toEpochDay()).toInt() + 1 + } + } + val avgPeriodLength = (if (periodLengths.isEmpty()) 5 + else periodLengths.takeLast(12).average().roundToInt()) + .coerceIn(2, 10) val tier = when { completedLengths.size >= 12 -> 4 @@ -31,23 +42,20 @@ class CyclePredictionEngine @Inject constructor() { else -> 1 } - val latestStart = records.maxByOrNull { it.cycleStart }?.let { LocalDate.parse(it.cycleStart) } + val latestStart = records.maxByOrNull { it.cycleStart } + ?.let { LocalDate.parse(it.cycleStart) } ?: return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd) val cycleDay = (today.toEpochDay() - latestStart.toEpochDay()).toInt() + 1 - val nextPeriod = latestStart.plusDays(avgLength.toLong()) - val lutealLength = 14 - val ovulation = latestStart.plusDays((avgLength - lutealLength).toLong()) - val fertileStart = ovulation.minusDays(5) - val fertileEnd = ovulation.plusDays(1) + val nextPeriod = latestStart.plusDays(avgCycleLength.toLong()) + val ovulation = latestStart.plusDays((avgCycleLength - LUTEAL_PHASE_LENGTH).toLong()) + val fertileStart = ovulation.minusDays(FERTILE_WINDOW_LEAD.toLong()) + val fertileEnd = ovulation.minusDays(1) val phaseMap = buildPhaseMap( records = records, - latestStart = latestStart, - avgLength = avgLength, - ovulation = ovulation, - fertileStart = fertileStart, - fertileEnd = fertileEnd, + avgCycleLength = avgCycleLength, + avgPeriodLength = avgPeriodLength, rangeStart = rangeStart, rangeEnd = rangeEnd ) @@ -62,7 +70,7 @@ class CyclePredictionEngine @Inject constructor() { fertileWindowStart = fertileStart, fertileWindowEnd = fertileEnd, ovulationDate = ovulation, - averageCycleLength = avgLength, + averageCycleLength = avgCycleLength, cyclesLogged = records.size, tier = tier, daysUntilNextPeriod = (nextPeriod.toEpochDay() - today.toEpochDay()).toInt().takeIf { it >= 0 }, @@ -75,85 +83,153 @@ class CyclePredictionEngine @Inject constructor() { today: LocalDate, rangeStart: LocalDate, rangeEnd: LocalDate - ): CyclePrediction { - return CyclePrediction( - currentCycleStartDate = null, - currentCycleDay = 0, - currentPhase = CyclePhase.NO_DATA, - nextPeriodDate = null, - fertileWindowStart = null, - fertileWindowEnd = null, - ovulationDate = null, - averageCycleLength = defaultLength, - cyclesLogged = 0, - tier = 1, - daysUntilNextPeriod = null, - phaseMap = emptyMap() - ) - } + ): CyclePrediction = CyclePrediction( + currentCycleStartDate = null, + currentCycleDay = 0, + currentPhase = CyclePhase.NO_DATA, + nextPeriodDate = null, + fertileWindowStart = null, + fertileWindowEnd = null, + ovulationDate = null, + averageCycleLength = defaultLength, + cyclesLogged = 0, + tier = 1, + daysUntilNextPeriod = null, + phaseMap = emptyMap() + ) private fun buildPhaseMap( records: List, - latestStart: LocalDate, - avgLength: Int, - ovulation: LocalDate, - fertileStart: LocalDate, - fertileEnd: LocalDate, + avgCycleLength: Int, + avgPeriodLength: Int, rangeStart: LocalDate, rangeEnd: LocalDate ): Map { val map = mutableMapOf() + if (records.isEmpty()) return map - // Mark confirmed period days from actual records - records.forEach { record -> - val start = LocalDate.parse(record.cycleStart) - val end = record.cycleEnd?.let { LocalDate.parse(it) } ?: start.plusDays(4) - var d = start - while (!d.isAfter(end) && !d.isAfter(rangeEnd)) { - if (!d.isBefore(rangeStart)) map[d] = CyclePhase.MENSTRUATION_CONFIRMED - d = d.plusDays(1) - } + val sortedRecords = records.sortedBy { it.cycleStart } + + // Each actual cycle record (its cycle window ends when the next record begins, + // or projects forward by avgCycleLength if it's the last one) + sortedRecords.forEachIndexed { index, record -> + val cycleStart = LocalDate.parse(record.cycleStart) + val confirmedEnd = record.cycleEnd?.let { LocalDate.parse(it) } + val periodEnd = confirmedEnd ?: cycleStart.plusDays((avgPeriodLength - 1).toLong()) + val nextStart = sortedRecords.getOrNull(index + 1) + ?.let { LocalDate.parse(it.cycleStart) } + ?: cycleStart.plusDays(avgCycleLength.toLong()) + + markCycleWindow( + map = map, + cycleStart = cycleStart, + periodEnd = periodEnd, + isPeriodConfirmed = confirmedEnd != null, + nextCycleStart = nextStart, + avgCycleLength = avgCycleLength, + rangeStart = rangeStart, + rangeEnd = rangeEnd + ) } - // Project forward from latestStart through rangeEnd in cycle increments - var cycleStart = latestStart - while (cycleStart.isBefore(rangeEnd)) { - val cycleOvulation = cycleStart.plusDays((avgLength - 14).toLong()) - val cycleFertileStart = cycleOvulation.minusDays(5) - val cycleFertileEnd = cycleOvulation.plusDays(1) - val cycleEnd = cycleStart.plusDays(avgLength.toLong()) - val periodEnd = cycleStart.plusDays(4) - - // Period days (predicted if future, already marked confirmed if past) - var d = cycleStart - while (!d.isAfter(periodEnd) && !d.isAfter(rangeEnd)) { - if (!d.isBefore(rangeStart) && map[d] == null) { - map[d] = CyclePhase.MENSTRUATION_PREDICTED - } - d = d.plusDays(1) - } - - // Fertile window - d = cycleFertileStart - while (!d.isAfter(cycleFertileEnd) && !d.isAfter(rangeEnd)) { - if (!d.isBefore(rangeStart) && map[d] == null) { - map[d] = if (d == cycleOvulation) CyclePhase.OVULATION_PREDICTED else CyclePhase.FERTILE_WINDOW_PREDICTED - } - d = d.plusDays(1) - } - - // Luteal phase - d = cycleFertileEnd.plusDays(1) - while (!d.isBefore(cycleStart) && !d.isAfter(cycleEnd.minusDays(1)) && !d.isAfter(rangeEnd)) { - if (!d.isBefore(rangeStart) && map[d] == null) { - map[d] = CyclePhase.LUTEAL - } - d = d.plusDays(1) - } - - cycleStart = cycleEnd + // Project predicted cycles forward until we cover rangeEnd + var projectedStart = LocalDate.parse(sortedRecords.last().cycleStart) + .plusDays(avgCycleLength.toLong()) + while (projectedStart.isBefore(rangeEnd)) { + val periodEnd = projectedStart.plusDays((avgPeriodLength - 1).toLong()) + val nextStart = projectedStart.plusDays(avgCycleLength.toLong()) + markCycleWindow( + map = map, + cycleStart = projectedStart, + periodEnd = periodEnd, + isPeriodConfirmed = false, + nextCycleStart = nextStart, + avgCycleLength = avgCycleLength, + rangeStart = rangeStart, + rangeEnd = rangeEnd + ) + projectedStart = nextStart } return map } + + /** + * Paints exactly one phase per day across [cycleStart, nextCycleStart-1]: + * menstruation → follicular (amber) → fertile window → ovulation → luteal (amber) + * No gaps. The follicular range starts the day after the period ends. + */ + private fun markCycleWindow( + map: MutableMap, + cycleStart: LocalDate, + periodEnd: LocalDate, + isPeriodConfirmed: Boolean, + nextCycleStart: LocalDate, + avgCycleLength: Int, + rangeStart: LocalDate, + rangeEnd: LocalDate + ) { + val ovulation = cycleStart.plusDays((avgCycleLength - LUTEAL_PHASE_LENGTH).toLong()) + val fertileStart = ovulation.minusDays(FERTILE_WINDOW_LEAD.toLong()) + val fertileEnd = ovulation.minusDays(1) + val cycleLastDay = nextCycleStart.minusDays(1) + + // 1. Menstruation: cycleStart day is always confirmed (it was explicitly logged). + // The remaining period days are confirmed only when cycle_end is logged. + val periodLastDay = minOf(periodEnd, cycleLastDay) + putIfInRange(map, cycleStart, CyclePhase.MENSTRUATION_CONFIRMED, rangeStart, rangeEnd) + val tailPhase = if (isPeriodConfirmed) + CyclePhase.MENSTRUATION_CONFIRMED else CyclePhase.MENSTRUATION_PREDICTED + fillRange(map, cycleStart.plusDays(1), periodLastDay, tailPhase, rangeStart, rangeEnd) + + // 2. Post-period follicular (rendered amber alongside luteal) + val postPeriodStart = periodLastDay.plusDays(1) + val postPeriodEnd = minOf(fertileStart.minusDays(1), cycleLastDay) + fillRange(map, postPeriodStart, postPeriodEnd, CyclePhase.FOLLICULAR, rangeStart, rangeEnd) + + // 3. Fertile window (excludes ovulation day) + val fwStart = maxOf(fertileStart, postPeriodStart) + val fwEnd = minOf(fertileEnd, cycleLastDay) + fillRange(map, fwStart, fwEnd, CyclePhase.FERTILE_WINDOW_PREDICTED, rangeStart, rangeEnd) + + // 4. Ovulation (single day) — only if it falls after the period and within the cycle + if (!ovulation.isBefore(postPeriodStart) && !ovulation.isAfter(cycleLastDay)) { + putIfInRange(map, ovulation, CyclePhase.OVULATION_PREDICTED, rangeStart, rangeEnd) + } + + // 5. Post-ovulation luteal — fills the remainder up to nextCycleStart - 1 + val lutealStart = maxOf(ovulation.plusDays(1), postPeriodStart) + fillRange(map, lutealStart, cycleLastDay, CyclePhase.LUTEAL, rangeStart, rangeEnd) + } + + private fun fillRange( + map: MutableMap, + start: LocalDate, + end: LocalDate, + phase: CyclePhase, + rangeStart: LocalDate, + rangeEnd: LocalDate + ) { + if (start.isAfter(end)) return + var d = start + while (!d.isAfter(end) && !d.isAfter(rangeEnd)) { + if (!d.isBefore(rangeStart)) map[d] = phase + d = d.plusDays(1) + } + } + + private fun putIfInRange( + map: MutableMap, + date: LocalDate, + phase: CyclePhase, + rangeStart: LocalDate, + rangeEnd: LocalDate + ) { + if (!date.isBefore(rangeStart) && !date.isAfter(rangeEnd)) map[date] = phase + } + + companion object { + private const val LUTEAL_PHASE_LENGTH = 14 + private const val FERTILE_WINDOW_LEAD = 5 + } } diff --git a/app/src/main/java/com/hsdiary/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/hsdiary/ui/calendar/CalendarScreen.kt index dfad647..ddd92c9 100644 --- a/app/src/main/java/com/hsdiary/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/calendar/CalendarScreen.kt @@ -3,18 +3,23 @@ package com.hsdiary.ui.calendar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState 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.TrendingUp -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text 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.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -22,15 +27,17 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.hsdiary.data.model.CyclePhase import com.hsdiary.data.model.ProfileType -import com.hsdiary.ui.components.AvatarDot +import com.hsdiary.domain.model.CyclePrediction +import com.hsdiary.ui.components.DiaryPillChip +import com.hsdiary.ui.components.DiaryTopBar +import com.hsdiary.ui.components.GlowDot import com.hsdiary.ui.components.ProfileSwitchSheet import com.hsdiary.ui.theme.* import java.time.LocalDate -import java.time.YearMonth -import java.time.format.TextStyle +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle as JTextStyle import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( onDayClick: (String) -> Unit, @@ -41,83 +48,167 @@ fun CalendarScreen( ) { val state by viewModel.uiState.collectAsState() val activeProfile = state.activeProfile + val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("H&S Diary", style = MaterialTheme.typography.titleLarge) - if (activeProfile != null) { - Text( - activeProfile.name, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - }, - actions = { - IconButton(onClick = onTrendsClick) { - Icon(Icons.AutoMirrored.Filled.TrendingUp, contentDescription = "Trends") - } - IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = "Settings") - } - if (activeProfile != null) { - val avatarColor = parseColor(activeProfile.avatarColor) - IconButton(onClick = { viewModel.showProfileSheet() }) { - AvatarDot(name = activeProfile.name, avatarColor = avatarColor, size = 32) - } - } - } - ) - } - ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - // Context banner (female only) - if (activeProfile?.profileType == ProfileType.FEMALE.name) { - val prediction = state.prediction - ContextBanner( - prediction = prediction, - onClick = onInsightsClick - ) - } - - // Month navigation - MonthHeader( - month = state.currentMonth, - onPrevious = viewModel::previousMonth, - onNext = viewModel::nextMonth - ) - - // Day-of-week headers - val dayHeaders = if (state.firstDayOfWeek == 1) - listOf("S","M","T","W","T","F","S") - else - listOf("M","T","W","T","F","S","S") - Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { - dayHeaders.forEach { h -> - Text( - text = h, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + Column( + modifier = Modifier + .fillMaxSize() + .background(BgColor) + ) { + // ── Top bar ───────────────────────────────────────────────────────── + DiaryTopBar( + subtitle = activeProfile?.name, + actions = { + IconButton( + onClick = onTrendsClick, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.TrendingUp, + contentDescription = "Trends", + tint = FgMutedColor, + modifier = Modifier.size(18.dp) ) } + IconButton( + onClick = onSettingsClick, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Settings, + contentDescription = "Settings", + tint = FgMutedColor, + modifier = Modifier.size(18.dp) + ) + } + if (activeProfile != null) { + Box( + modifier = Modifier + .size(30.dp) + .background(parseColor(activeProfile.avatarColor), CircleShape) + .clickable { viewModel.showProfileSheet() }, + contentAlignment = Alignment.Center + ) { + Text( + text = activeProfile.name.take(1).uppercase(), + fontFamily = InstrumentSans, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = BgColor + ) + } + } } + ) - // Calendar grid - val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name - CalendarGrid( - days = state.dayStates, + // ── Cycle status banner (female profiles only) ─────────────────────── + if (isFemale) { + CycleStatusBanner( + prediction = state.prediction, + onClick = onInsightsClick + ) + } + + // ── Month navigation ───────────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 14.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = viewModel::previousMonth) { + Text("‹", fontFamily = CormorantGaramond, fontSize = 22.sp, color = FgMutedColor) + } + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = state.currentMonth.month.getDisplayName(JTextStyle.FULL, Locale.getDefault()), + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + lineHeight = 23.sp, + color = FgColor + ) + Text( + text = state.currentMonth.year.toString(), + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 2.sp + ) + } + IconButton(onClick = viewModel::nextMonth) { + Text("›", fontFamily = CormorantGaramond, fontSize = 22.sp, color = FgMutedColor) + } + } + + // ── Day-of-week headers ────────────────────────────────────────────── + val dowLabels = if (state.firstDayOfWeek == 1) + listOf("S", "M", "T", "W", "T", "F", "S") + else + listOf("M", "T", "W", "T", "F", "S", "S") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp) + .padding(bottom = 4.dp) + ) { + dowLabels.forEach { label -> + Text( + text = label, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 1.sp + ) + } + } + + // ── Calendar grid ──────────────────────────────────────────────────── + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 4.dp) + ) { + state.dayStates.chunked(7).forEach { week -> + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + week.forEach { day -> + AubergineDay( + day = day, + isFemale = isFemale, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + onClick = { onDayClick(day.date.toString()) } + ) + } + } + } + } + + // ── Today footer ───────────────────────────────────────────────────── + val today = state.dayStates.firstOrNull { it.isToday } + if (today != null) { + TodayFooter( + today = today, isFemale = isFemale, - onDayClick = { onDayClick(it.toString()) } + onTap = { onDayClick(today.date.toString()) } ) } } + // Profile switch sheet if (state.showProfileSheet && state.profiles.size > 1) { ProfileSwitchSheet( profiles = viewModel.getProfileSwitchItems(state), @@ -127,183 +218,227 @@ fun CalendarScreen( } } +// ─── Cycle status banner ───────────────────────────────────────────────────── @Composable -private fun ContextBanner( - prediction: com.hsdiary.domain.model.CyclePrediction?, - onClick: () -> Unit -) { - val text = when { - prediction == null || prediction.cyclesLogged == 0 -> - "🩸 Set up your cycle — log your first period to begin" - prediction.currentPhase == CyclePhase.MENSTRUATION_CONFIRMED || - prediction.currentPhase == CyclePhase.MENSTRUATION_PREDICTED -> - "🩸 Period · Day ${prediction.currentCycleDay}" +private fun CycleStatusBanner(prediction: CyclePrediction?, onClick: () -> Unit) { + val (dotColor, phaseText, subtitle) = when { + prediction == null || prediction.cyclesLogged == 0 -> Triple( + FgFaintColor, + "Start tracking your cycle", + "Log your first period to begin" + ) + prediction.currentPhase == CyclePhase.MENSTRUATION_CONFIRMED -> Triple( + PeriodColor, + "Period · day ${prediction.currentCycleDay}", + "Cycle day ${prediction.currentCycleDay}" + + (prediction.daysUntilNextPeriod?.let { " · ends in ~$it days" } ?: "") + ) + prediction.currentPhase == CyclePhase.MENSTRUATION_PREDICTED -> Triple( + PeriodPredictedColor, + "Period predicted", + "Tap \"Period started\" on today to confirm" + ) prediction.currentPhase == CyclePhase.OVULATION || - prediction.currentPhase == CyclePhase.OVULATION_PREDICTED -> - "🌿 Ovulation day" + prediction.currentPhase == CyclePhase.OVULATION_PREDICTED -> Triple( + OvulationColor, + "Ovulation day", + "Cycle day ${prediction.currentCycleDay} · next period in ~${prediction.daysUntilNextPeriod ?: "?"} days" + ) prediction.currentPhase == CyclePhase.FERTILE_WINDOW || - prediction.currentPhase == CyclePhase.FERTILE_WINDOW_PREDICTED -> { - val remaining = prediction.fertileWindowEnd?.let { - (it.toEpochDay() - LocalDate.now().toEpochDay()).toInt() - } ?: 0 - "🌿 Fertile window · ~$remaining days remaining" - } - else -> { - val days = prediction.daysUntilNextPeriod - if (days != null) "🩸 Next period in ~$days days · Cycle day ${prediction.currentCycleDay}" - else "🩸 Cycle day ${prediction.currentCycleDay}" - } - } - - Surface( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - color = MaterialTheme.colorScheme.primaryContainer, - tonalElevation = 1.dp - ) { - Text( - text = text, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer + prediction.currentPhase == CyclePhase.FERTILE_WINDOW_PREDICTED -> Triple( + FertileColor, + "Fertile window", + "Cycle day ${prediction.currentCycleDay}" + + (prediction.daysUntilNextPeriod?.let { " · next period in ~$it days" } ?: "") + ) + else -> Triple( + LutealColor, + "Cycle day ${prediction.currentCycleDay}", + prediction.daysUntilNextPeriod?.let { "Next period in ~$it days" } ?: "Tracking" ) } -} -@Composable -private fun MonthHeader(month: YearMonth, onPrevious: () -> Unit, onNext: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - IconButton(onClick = onPrevious) { - Icon(Icons.Default.ChevronLeft, contentDescription = "Previous month") - } - Text( - text = "${month.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${month.year}", - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - IconButton(onClick = onNext) { - Icon(Icons.Default.ChevronRight, contentDescription = "Next month") + GlowDot(color = dotColor) + Column(modifier = Modifier.weight(1f)) { + Text( + text = phaseText, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 16.sp, + lineHeight = 19.sp, + color = FgColor + ) + Text( + text = subtitle.uppercase(), + fontFamily = JetBrainsMono, + fontSize = 10.5.sp, + color = FgSubtleColor, + letterSpacing = 0.63.sp, + modifier = Modifier.padding(top = 3.dp) + ) } + Text(text = "›", fontFamily = CormorantGaramond, fontSize = 18.sp, color = FgFaintColor) } + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) } +// ─── Day cell ──────────────────────────────────────────────────────────────── @Composable -private fun CalendarGrid( - days: List, - isFemale: Boolean, - onDayClick: (LocalDate) -> Unit -) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { - days.chunked(7).forEach { week -> - Row(modifier = Modifier.fillMaxWidth()) { - week.forEach { day -> - DayCell( - day = day, - isFemale = isFemale, - onClick = { onDayClick(day.date) }, - modifier = Modifier.weight(1f) - ) - } - } - } - } -} - -@Composable -private fun DayCell( +private fun AubergineDay( day: DayState, isFemale: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onClick: () -> Unit ) { - val phaseColor = if (isFemale) phaseColor(day.phase) else null - val textAlpha = if (day.isCurrentMonth) 1f else 0.35f + val phase = if (isFemale) phaseColorForDot(day.phase) else null + val isPeriodConfirmed = day.phase == CyclePhase.MENSTRUATION_CONFIRMED + val isPredicted = isFemale && isPredicted(day.phase) + val icons = buildIconList(day, isFemale) - Box( + // Ring style visual + val bubbleBg: Color = if (isPeriodConfirmed) (phase ?: Color.Transparent) else Color.Transparent + val bubbleBorderColor: Color = when { + day.isToday && !isPeriodConfirmed -> AccentColor + phase != null && !isPeriodConfirmed -> + if (isPredicted) phase.copy(alpha = 0.55f) else phase + isPeriodConfirmed -> phase ?: Color.Transparent + else -> Color.Transparent + } + val bubbleBorderWidth = if (day.isToday) 1.5.dp else 1.dp + + Column( modifier = modifier - .aspectRatio(1f) - .padding(1.dp) - .clip(RoundedCornerShape(8.dp)) .clickable(onClick = onClick) + .alpha(if (day.isCurrentMonth) 1f else 0.35f) + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Phase color band at bottom - if (phaseColor != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.25f) - .align(Alignment.BottomCenter) - .background( - phaseColor.copy( - alpha = if (isPredicted(day.phase)) 0.45f else 0.75f - ) - ) - ) - } - - // Today ring - if (day.isToday) { - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .align(Alignment.TopCenter) - .offset(y = 3.dp) - ) - } - - Column( - modifier = Modifier.fillMaxSize().padding(2.dp), - horizontalAlignment = Alignment.CenterHorizontally + // Number bubble + Box( + modifier = Modifier + .size(30.dp) + .background(bubbleBg, CircleShape) + .then( + if (bubbleBorderColor != Color.Transparent) + Modifier.border(bubbleBorderWidth, bubbleBorderColor, CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center ) { - // Day number Text( text = day.date.dayOfMonth.toString(), - style = MaterialTheme.typography.bodySmall, - color = if (day.isToday) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha), - fontWeight = if (day.isToday) FontWeight.Bold else FontWeight.Normal, - modifier = Modifier.padding(top = 4.dp) + fontFamily = JetBrainsMono, + fontSize = 12.sp, + fontWeight = if (isPeriodConfirmed) FontWeight.SemiBold else FontWeight.Normal, + color = when { + isPredicted -> phase ?: FgColor + else -> FgColor + } ) + } - // Icon row - val icons = buildIconList(day, isFemale) - if (icons.isNotEmpty()) { - Row( - modifier = Modifier.padding(top = 1.dp), - horizontalArrangement = Arrangement.Center - ) { - val visibleIcons = if (day.hasIntimacy) icons.take(2) else icons.take(3) - val overflow = icons.size - visibleIcons.size - visibleIcons.forEach { icon -> - Text(icon, fontSize = 9.sp, lineHeight = 10.sp) - } - if (overflow > 0) Text("+$overflow", fontSize = 7.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - if (day.hasIntimacy) Text("❤️", fontSize = 9.sp, lineHeight = 10.sp) + // Icon row (up to 2 condition icons + intimacy heart) + if (day.isCurrentMonth && (icons.isNotEmpty() || day.hasIntimacy)) { + Spacer(Modifier.height(2.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icons.take(2).forEach { icon -> + Text(icon, fontSize = 8.5.sp, lineHeight = 10.sp) + } + if (day.hasIntimacy) { + Text("❤", fontSize = 8.5.sp, lineHeight = 10.sp, color = AccentColor) } } } } } +// ─── Today footer ───────────────────────────────────────────────────────────── +@Composable +private fun TodayFooter(today: DayState, isFemale: Boolean, onTap: () -> Unit) { + val fmt = DateTimeFormatter.ofPattern("EEEE, MMMM d", Locale.getDefault()) + val label = "TODAY · ${today.date.format(fmt).uppercase()}" + + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp) + ) { + Text( + text = label, + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 1.6.sp + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Phase chip (female only, if a phase is active) + if (isFemale && today.phase != CyclePhase.NO_DATA) { + val (emoji, label2) = phaseChipData(today.phase) + DiaryPillChip(selected = true, leading = emoji) { + Text(label2, fontFamily = InstrumentSans, fontSize = 12.sp, color = FgColor) + } + } + // Intimacy chip + if (today.hasIntimacy) { + DiaryPillChip(leading = "❤") { + Text("Encounter logged", fontFamily = InstrumentSans, fontSize = 12.sp, color = FgMutedColor) + } + } + // Add note chip + DiaryPillChip(onClick = onTap) { + Text("+ Add note", fontFamily = InstrumentSans, fontSize = 12.sp, color = FgMutedColor) + } + } + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── +private fun phaseColorForDot(phase: CyclePhase): Color? = when (phase) { + CyclePhase.MENSTRUATION_CONFIRMED -> PeriodColor + CyclePhase.MENSTRUATION_PREDICTED -> PeriodPredictedColor + CyclePhase.FERTILE_WINDOW, + CyclePhase.FERTILE_WINDOW_PREDICTED -> FertileColor + CyclePhase.OVULATION, + CyclePhase.OVULATION_PREDICTED -> OvulationColor + CyclePhase.LUTEAL, + CyclePhase.FOLLICULAR -> LutealColor + else -> null +} + +private fun phaseChipData(phase: CyclePhase): Pair = when (phase) { + CyclePhase.MENSTRUATION_CONFIRMED -> "🩸" to "Period" + CyclePhase.MENSTRUATION_PREDICTED -> "🩸" to "Period (predicted)" + CyclePhase.OVULATION, + CyclePhase.OVULATION_PREDICTED -> "🌿" to "Ovulation" + CyclePhase.FERTILE_WINDOW, + CyclePhase.FERTILE_WINDOW_PREDICTED -> "🌿" to "Fertile window" + CyclePhase.LUTEAL -> "🌙" to "Luteal" + CyclePhase.FOLLICULAR -> "🌱" to "Follicular" + else -> "•" to "Tracking" +} + private fun buildIconList(day: DayState, isFemale: Boolean): List { val icons = mutableListOf() - if (isFemale && (day.periodActive || day.phase == CyclePhase.MENSTRUATION_CONFIRMED || day.phase == CyclePhase.MENSTRUATION_PREDICTED)) { - icons.add("🩸") - } - day.conditionKeys.take(3).forEach { key -> - icons.add(conditionIcon(key)) - } + day.conditionKeys.take(2).forEach { key -> icons.add(conditionIcon(key)) } return icons } private fun conditionIcon(key: String): String = when { key.contains("HEAD") || key.contains("MIGRAINE") -> "⚡" - key.contains("FATIGUE") || key.contains("EXHAUST") -> "😴" + key.contains("FATIGUE") || key.contains("EXHAUST") || key.contains("SLEEP") -> "😴" key.contains("NAUSEA") || key.contains("VOMIT") -> "🤢" key.contains("CRAMP") -> "💫" key.contains("BACK") || key.contains("NECK") || key.contains("JOINT") -> "🦴" @@ -313,18 +448,15 @@ private fun conditionIcon(key: String): String = when { else -> "•" } -private fun phaseColor(phase: CyclePhase): Color? = when (phase) { - CyclePhase.MENSTRUATION_CONFIRMED -> PeriodColor - CyclePhase.MENSTRUATION_PREDICTED -> PeriodPredictedColor - CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> FertileColor - CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> OvulationColor - CyclePhase.LUTEAL -> LutealColor - else -> null +// MENSTRUATION_CONFIRMED is the only fully solid phase; all others are predicted/projected +private fun isPredicted(phase: CyclePhase): Boolean = when (phase) { + CyclePhase.MENSTRUATION_CONFIRMED -> false + else -> true } -private fun isPredicted(phase: CyclePhase): Boolean = phase == CyclePhase.MENSTRUATION_PREDICTED || - phase == CyclePhase.FERTILE_WINDOW_PREDICTED || phase == CyclePhase.OVULATION_PREDICTED - fun parseColor(hex: String): Color = try { Color(android.graphics.Color.parseColor(hex)) -} catch (e: Exception) { Color(0xFFE91E63) } +} catch (e: Exception) { + AccentColor +} + diff --git a/app/src/main/java/com/hsdiary/ui/components/ProfileChip.kt b/app/src/main/java/com/hsdiary/ui/components/ProfileChip.kt index c739961..a6ec3dc 100644 --- a/app/src/main/java/com/hsdiary/ui/components/ProfileChip.kt +++ b/app/src/main/java/com/hsdiary/ui/components/ProfileChip.kt @@ -69,9 +69,10 @@ fun AvatarDot( ) { Text( text = name.take(1).uppercase(), - color = Color.White, - fontSize = (size * 0.38).sp, - fontWeight = FontWeight.Bold + // Design spec: dark near-black initial on all avatar colours (th.accentOn) + color = Color(0xFF1C1015), + fontSize = (size * 0.40).sp, + fontWeight = FontWeight.SemiBold ) } } diff --git a/app/src/main/java/com/hsdiary/ui/components/ProfileSwitchSheet.kt b/app/src/main/java/com/hsdiary/ui/components/ProfileSwitchSheet.kt index 0879df2..3b9778a 100644 --- a/app/src/main/java/com/hsdiary/ui/components/ProfileSwitchSheet.kt +++ b/app/src/main/java/com/hsdiary/ui/components/ProfileSwitchSheet.kt @@ -1,13 +1,19 @@ package com.hsdiary.ui.components +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.hsdiary.ui.theme.* data class ProfileSwitchItem( val id: Long, @@ -25,14 +31,47 @@ fun ProfileSwitchSheet( ) { var confirmTarget by remember { mutableStateOf(null) } - ModalBottomSheet(onDismissRequest = onDismiss) { - Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp)) { - Text( - text = "Switch Profile", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 16.dp) + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = SurfaceColor, + dragHandle = { + Box( + modifier = Modifier + .padding(top = 12.dp) + .size(width = 36.dp, height = 4.dp) + .background(BorderSoftColor, RoundedCornerShape(2.dp)) ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp) + .padding(bottom = 28.dp) + ) { + Spacer(Modifier.height(4.dp)) + + // Title + Text( + text = "Switch diary", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 20.sp, + color = FgColor + ) + val lastOpened = "last opened just now" + Text( + text = "${profiles.size} profiles · $lastOpened".uppercase(), + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 1.2.sp, + modifier = Modifier.padding(top = 2.dp, bottom = 14.dp) + ) + + // Profile rows profiles.forEach { profile -> + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) Row( modifier = Modifier .fillMaxWidth() @@ -40,38 +79,112 @@ fun ProfileSwitchSheet( if (!profile.isActive) confirmTarget = profile else onDismiss() } - .padding(vertical = 12.dp), + .padding(vertical = 14.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(14.dp) ) { - AvatarDot(name = profile.name, avatarColor = profile.avatarColor) - Text( - text = profile.name, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) + AvatarDot( + name = profile.name, + avatarColor = profile.avatarColor, + size = 36 ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.name, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 17.sp, + color = FgColor + ) + Text( + text = if (profile.isActive) "CURRENTLY OPEN" else "TAP TO SWITCH", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 1.sp, + modifier = Modifier.padding(top = 2.dp) + ) + } if (profile.isActive) { - Badge { Text("Active") } + Box( + modifier = Modifier + .background(Color.Transparent, RoundedCornerShape(999.dp)) + .border(1.dp, AccentFaintColor, RoundedCornerShape(999.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + "ACTIVE", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = AccentColor, + letterSpacing = 1.2.sp + ) + } } } } + + Spacer(Modifier.height(14.dp)) + + // Add profile button — outline style + Box( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderColor, RoundedCornerShape(999.dp)) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "+ Add a profile", + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = FgColor + ) + } } } + // Confirm switch dialog confirmTarget?.let { target -> AlertDialog( onDismissRequest = { confirmTarget = null }, - title = { Text("Switch Profile") }, - text = { Text("Switch to ${target.name}?") }, + containerColor = SurfaceColor, + title = { + Text( + "Switch diary", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 20.sp, + color = FgColor + ) + }, + text = { + Text( + "Switch to ${target.name}?", + fontFamily = InstrumentSans, + fontSize = 14.sp, + color = FgMutedColor + ) + }, confirmButton = { - TextButton(onClick = { - onProfileSelected(target.id) - confirmTarget = null - onDismiss() - }) { Text("Switch") } + TextButton( + onClick = { + onProfileSelected(target.id) + confirmTarget = null + onDismiss() + }, + colors = ButtonDefaults.textButtonColors(contentColor = AccentColor) + ) { + Text("Switch", fontFamily = InstrumentSans) + } }, dismissButton = { - TextButton(onClick = { confirmTarget = null }) { Text("Cancel") } + TextButton( + onClick = { confirmTarget = null }, + colors = ButtonDefaults.textButtonColors(contentColor = FgMutedColor) + ) { + Text("Cancel", fontFamily = InstrumentSans) + } } ) } diff --git a/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt b/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt index db98e27..1484239 100644 --- a/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt @@ -1,25 +1,36 @@ package com.hsdiary.ui.daydetail import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.hsdiary.data.db.entity.ConditionDefinitionEntity import com.hsdiary.data.db.entity.IntimacyLogEntity import com.hsdiary.data.model.ConditionCategory import com.hsdiary.data.model.CyclePhase +import com.hsdiary.ui.components.DiaryCard +import com.hsdiary.ui.components.DiaryTextLink +import com.hsdiary.ui.components.DiaryTopBar +import com.hsdiary.ui.components.OrnamentDivider +import com.hsdiary.ui.components.SectionLabel +import com.hsdiary.ui.theme.* import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale @@ -32,8 +43,8 @@ fun DayDetailScreen( viewModel: DayDetailViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() - var savedSnackbar by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } + var savedSnackbar by remember { mutableStateOf(false) } LaunchedEffect(savedSnackbar) { if (savedSnackbar) { @@ -42,8 +53,12 @@ fun DayDetailScreen( } } - val dateLabel = remember(date) { - LocalDate.parse(date).format(DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.getDefault())) + val parsedDate = remember(date) { LocalDate.parse(date) } + val dayOfWeek = remember(parsedDate) { + parsedDate.format(DateTimeFormatter.ofPattern("EEEE", Locale.getDefault())) + } + val subtitle = remember(parsedDate) { + parsedDate.format(DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault())) } BackHandler { @@ -55,94 +70,135 @@ fun DayDetailScreen( } Scaffold( + containerColor = BgColor, snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { Text(dateLabel, style = MaterialTheme.typography.titleMedium) }, - navigationIcon = { - IconButton(onClick = { - if (state.isDirty) viewModel.saveAndExit() - onBack() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(BgColor) + ) { + DiaryTopBar( + showBack = true, + onBack = { + if (state.isDirty) viewModel.saveAndExit() + onBack() + }, + title = dayOfWeek, + subtitle = buildTopBarSubtitle(subtitle, state), + actions = { + if (state.isDirty) { + Text( + "SAVING", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = AccentDimColor, + letterSpacing = 0.5.sp + ) + } else { + Text( + "SAVED", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 0.5.sp + ) } } ) - } - ) { padding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(bottom = 32.dp) - ) { - // Cycle section (female only) - if (state.isFemale) { + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp) + ) { + // I. Cycle (female only) + if (state.isFemale) { + item { + SectionLabel(title = "Cycle", num = "I.") + CycleSection( + state = state, + onTogglePeriodStart = viewModel::togglePeriodStart, + onTogglePeriodEnd = viewModel::togglePeriodEnd + ) + } + } + + // II. Symptoms item { - CycleSection( - state = state, - onTogglePeriodStart = viewModel::togglePeriodStart, - onTogglePeriodEnd = viewModel::togglePeriodEnd + SectionLabel(title = "Symptoms", num = if (state.isFemale) "II." else "I.") + ConditionsSection( + definitions = state.definitions, + selectedConditions = state.conditions, + onToggle = viewModel::toggleCondition, + onRatingChange = viewModel::setConditionRating ) } - item { HorizontalDivider() } - } - // Conditions section - item { - ConditionsSection( - definitions = state.definitions, - selectedConditions = state.conditions, - onToggle = viewModel::toggleCondition, - onRatingChange = viewModel::setConditionRating - ) - } + // III. Notes + item { + SectionLabel(title = "Notes", num = if (state.isFemale) "III." else "II.") + NotesSection( + notes = state.notes, + onNotesChange = viewModel::updateNotes + ) + } - // Notes - item { - NotesSection( - notes = state.notes, - onNotesChange = viewModel::updateNotes - ) - } + item { OrnamentDivider() } - item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } - - // Intimacy section - item { - IntimacySection( - logs = state.intimacyLogs, - profiles = state.allProfiles, - activeProfileId = state.activeProfile?.id ?: 0L, - isFemaleInFertileWindow = state.isFemale && ( - state.currentPhase == CyclePhase.FERTILE_WINDOW || - state.currentPhase == CyclePhase.OVULATION - ), - onAdd = { pType, pName, time, isProtected -> - viewModel.addIntimacyLog(pType, pName, time, isProtected) - }, - onDelete = viewModel::deleteIntimacyLog - ) + // IV. Intimacy + item { + SectionLabel(title = "Intimacy", num = if (state.isFemale) "IV." else "III.") + IntimacySection( + logs = state.intimacyLogs, + profiles = state.allProfiles, + activeProfileId = state.activeProfile?.id ?: 0L, + isFemaleInFertileWindow = state.isFemale && ( + state.currentPhase == CyclePhase.FERTILE_WINDOW || + state.currentPhase == CyclePhase.OVULATION + ), + onAdd = { pType, pName, time, isProtected -> + viewModel.addIntimacyLog(pType, pName, time, isProtected) + }, + onDelete = viewModel::deleteIntimacyLog + ) + } } } } } +private fun buildTopBarSubtitle(dateLabel: String, state: DayDetailUiState): String { + val parts = mutableListOf(dateLabel) + if (state.cycleDay > 0) parts.add("Cycle day ${state.cycleDay}") + val phaseLabel = when (state.currentPhase) { + CyclePhase.MENSTRUATION_CONFIRMED -> "Period" + CyclePhase.MENSTRUATION_PREDICTED -> "Period (predicted)" + CyclePhase.FERTILE_WINDOW, + CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile window" + CyclePhase.OVULATION, + CyclePhase.OVULATION_PREDICTED -> "Ovulation" + CyclePhase.LUTEAL -> "Luteal" + CyclePhase.FOLLICULAR -> "Follicular" + else -> null + } + if (phaseLabel != null) parts.add(phaseLabel) + return parts.joinToString(" · ") +} + +// ─── Cycle section ──────────────────────────────────────────────────────────── private enum class CycleDayUiState { - NO_DATA, // State 1: no data for this day → show Started (untapped) - START_CONFIRMED, // State 2: this day is cycle_start → show Started (confirmed ✓) - MID_OPEN, // State 3: mid-period, no end yet → show Ended (untapped) - END_CONFIRMED, // State 4: this day is cycle_end → show Ended (confirmed ✓) - BETWEEN_CYCLES, // State 5: post-period non-menstruation phase → hide both - PREDICTED // State 6: MENSTRUATION_PREDICTED → show Started (untapped) + NO_DATA, START_CONFIRMED, MID_OPEN, END_CONFIRMED, BETWEEN_CYCLES, PREDICTED } private fun cycleDayUiState(state: DayDetailUiState): CycleDayUiState = when { - state.isPeriodStart -> CycleDayUiState.START_CONFIRMED - state.isPeriodEnd -> CycleDayUiState.END_CONFIRMED - state.hasActiveCycle && state.cycleEndDate == null -> CycleDayUiState.MID_OPEN - state.hasActiveCycle -> CycleDayUiState.BETWEEN_CYCLES - state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED -> CycleDayUiState.PREDICTED - state.currentPhase == CyclePhase.NO_DATA -> CycleDayUiState.NO_DATA - else -> CycleDayUiState.BETWEEN_CYCLES + state.isPeriodStart -> CycleDayUiState.START_CONFIRMED + state.isPeriodEnd -> CycleDayUiState.END_CONFIRMED + state.hasActiveCycle && state.cycleEndDate == null -> CycleDayUiState.MID_OPEN + state.hasActiveCycle -> CycleDayUiState.BETWEEN_CYCLES + state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED -> CycleDayUiState.PREDICTED + state.currentPhase == CyclePhase.NO_DATA -> CycleDayUiState.NO_DATA + else -> CycleDayUiState.BETWEEN_CYCLES } @Composable @@ -153,41 +209,43 @@ private fun CycleSection( ) { val cycleState = cycleDayUiState(state) - Column(modifier = Modifier.padding(16.dp)) { - // Phase / cycle-day header - val phaseLabel = when (state.currentPhase) { - CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation" - CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation" - CyclePhase.FERTILE_WINDOW, - CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window" - CyclePhase.OVULATION, - CyclePhase.OVULATION_PREDICTED -> "Ovulation" - CyclePhase.LUTEAL -> "Luteal Phase" - CyclePhase.FOLLICULAR -> "Follicular Phase" - else -> null + Column(modifier = Modifier.padding(horizontal = 18.dp)) { + // Phase description — italic serif, phase colour + val phaseDescription = when (state.currentPhase) { + CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> + "Peak fertility — narrow window today." + CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> + "Fertile window open." + CyclePhase.MENSTRUATION_CONFIRMED -> + "Period · day ${state.periodDayNumber.takeIf { it > 0 } ?: ""}." + CyclePhase.MENSTRUATION_PREDICTED -> + "Period predicted today." + CyclePhase.LUTEAL -> + "Luteal phase." + CyclePhase.FOLLICULAR -> + "Follicular phase." + else -> null } - if (state.cycleDay > 0 && phaseLabel != null) { + val phaseColor = when (state.currentPhase) { + CyclePhase.MENSTRUATION_CONFIRMED, CyclePhase.MENSTRUATION_PREDICTED -> PeriodColor + CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> FertileColor + CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> OvulationColor + CyclePhase.LUTEAL, CyclePhase.FOLLICULAR -> LutealColor + else -> FgMutedColor + } + if (phaseDescription != null) { Text( - "Cycle day ${state.cycleDay} · $phaseLabel", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = phaseDescription, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + lineHeight = 20.sp, + color = phaseColor, + modifier = Modifier.padding(bottom = 12.dp) ) - Spacer(Modifier.height(4.dp)) } - // Period day badge — visible whenever a cycle record spans this date - if (state.hasActiveCycle && state.periodDayNumber > 0) { - Text( - "Period day ${state.periodDayNumber}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - Spacer(Modifier.height(8.dp)) - } - - Text("Period", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(12.dp)) - + // Period action chips val showStarted = cycleState == CycleDayUiState.NO_DATA || cycleState == CycleDayUiState.START_CONFIRMED || cycleState == CycleDayUiState.PREDICTED @@ -195,59 +253,85 @@ private fun CycleSection( cycleState == CycleDayUiState.END_CONFIRMED if (showStarted || showEnded) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (showStarted) { - FilterChip( + AubergineFilterChip( selected = cycleState == CycleDayUiState.START_CONFIRMED, onClick = onTogglePeriodStart, - label = { Text("Period started") }, - leadingIcon = { - Icon( - if (cycleState == CycleDayUiState.START_CONFIRMED) Icons.Default.Check - else Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } + label = "Period started", + leadingIcon = if (cycleState == CycleDayUiState.START_CONFIRMED) "✓" else "▶" ) } if (showEnded) { - FilterChip( + AubergineFilterChip( selected = cycleState == CycleDayUiState.END_CONFIRMED, onClick = onTogglePeriodEnd, - label = { Text("Period ended") }, - leadingIcon = { - Icon( - if (cycleState == CycleDayUiState.END_CONFIRMED) Icons.Default.Check - else Icons.Default.Stop, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } + label = "Period ended", + leadingIcon = if (cycleState == CycleDayUiState.END_CONFIRMED) "✓" else "■" ) } } } - // Predicted-period nudge (State 6 only) + // Predicted nudge if (cycleState == CycleDayUiState.PREDICTED) { Spacer(Modifier.height(8.dp)) - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon(Icons.Default.Info, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) - Text("Period predicted today — tap \"Period started\" to confirm", - style = MaterialTheme.typography.bodySmall) - } + Row( + modifier = Modifier + .fillMaxWidth() + .background(AccentFaintColor, RoundedCornerShape(10.dp)) + .border(1.dp, AccentFaintColor, RoundedCornerShape(10.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("🩸", fontSize = 14.sp) + Text( + "Period predicted today — tap \"Period started\" to confirm", + fontFamily = InstrumentSans, + fontSize = 12.sp, + color = FgMutedColor, + lineHeight = 16.sp + ) } } + + Spacer(Modifier.height(4.dp)) } } +@Composable +private fun AubergineFilterChip( + selected: Boolean, + onClick: () -> Unit, + label: String, + leadingIcon: String? = null +) { + Row( + modifier = Modifier + .background( + if (selected) AccentFaintColor else Color.Transparent, + RoundedCornerShape(999.dp) + ) + .border(1.dp, if (selected) AccentColor else BorderColor, RoundedCornerShape(999.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (leadingIcon != null) { + Text(leadingIcon, fontFamily = InstrumentSans, fontSize = 12.sp, color = if (selected) AccentColor else FgMutedColor) + } + Text( + label, + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = if (selected) FgColor else FgMutedColor + ) + } +} + +// ─── Conditions section ─────────────────────────────────────────────────────── @OptIn(ExperimentalLayoutApi::class) @Composable private fun ConditionsSection( @@ -256,21 +340,17 @@ private fun ConditionsSection( onToggle: (String) -> Unit, onRatingChange: (String, Int) -> Unit ) { - val grouped = remember(definitions) { - definitions.groupBy { it.category } - } + val grouped = remember(definitions) { definitions.groupBy { it.category } } var expandedCategories by remember { mutableStateOf(setOf()) } - Column(modifier = Modifier.padding(16.dp)) { - Text("Symptoms", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(12.dp)) - + Column(modifier = Modifier.padding(horizontal = 18.dp)) { grouped.forEach { (category, items) -> val categoryLabel = ConditionCategory.entries.find { it.name == category }?.displayName ?: category.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } val isExpanded = category in expandedCategories - val hasSelected = items.any { selectedConditions.containsKey(it.conditionKey) } + val selectedCount = items.count { selectedConditions.containsKey(it.conditionKey) } + // Category row Row( modifier = Modifier .fillMaxWidth() @@ -280,83 +360,116 @@ private fun ConditionsSection( else expandedCategories + category } - .padding(vertical = 8.dp), + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = categoryLabel, - style = MaterialTheme.typography.labelLarge, - color = if (hasSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, + text = categoryLabel.uppercase(), + fontFamily = InstrumentSans, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + letterSpacing = 0.6.sp, + color = if (selectedCount > 0 || isExpanded) FgColor else FgSubtleColor, modifier = Modifier.weight(1f) ) - if (hasSelected) { - Badge(containerColor = MaterialTheme.colorScheme.primary) { - Text("${items.count { selectedConditions.containsKey(it.conditionKey) }}") + if (selectedCount > 0) { + Box( + modifier = Modifier + .background(AccentFaintColor, RoundedCornerShape(999.dp)) + .border(1.dp, AccentFaintColor, RoundedCornerShape(999.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp) + ) { + Text( + text = selectedCount.toString(), + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = AccentColor + ) } + Spacer(Modifier.width(8.dp)) } - Icon( - if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - modifier = Modifier.size(20.dp) + Text( + text = if (isExpanded) "−" else "+", + fontFamily = JetBrainsMono, + fontSize = 12.sp, + color = FgFaintColor ) } + // Expanded content if (isExpanded) { FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(6.dp) ) { items.forEach { def -> val selected = selectedConditions.containsKey(def.conditionKey) - FilterChip( + AubergineFilterChip( selected = selected, onClick = { onToggle(def.conditionKey) }, - label = { Text(def.displayName, style = MaterialTheme.typography.bodySmall) } + label = def.displayName ) } } - // Rating row for selected items in this category + + // Rating rows for selected items.filter { selectedConditions.containsKey(it.conditionKey) }.forEach { def -> val rating = selectedConditions[def.conditionKey] ?: 3 - RatingRow( + AubergineRatingRow( label = def.displayName, rating = rating, onRatingChange = { onRatingChange(def.conditionKey, it) } ) } } + + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) } } } @Composable -private fun RatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit) { +private fun AubergineRatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(120.dp)) + Text( + text = label, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 13.sp, + color = FgColor, + modifier = Modifier.weight(1f) + ) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { (1..5).forEach { i -> Box( modifier = Modifier - .size(22.dp) + .size(20.dp) .background( - if (i <= rating) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(4.dp) + if (i <= rating) AccentColor else Color.Transparent, + RoundedCornerShape(4.dp) + ) + .border( + 1.dp, + if (i <= rating) AccentColor else BorderColor, + RoundedCornerShape(4.dp) ) .clickable { onRatingChange(i) }, contentAlignment = Alignment.Center ) { Text( - i.toString(), - style = MaterialTheme.typography.labelSmall, - color = if (i <= rating) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant + text = i.toString(), + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = if (i <= rating) AccentOnColor else FgFaintColor ) } } @@ -364,21 +477,61 @@ private fun RatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit) } } +// ─── Notes section ──────────────────────────────────────────────────────────── @Composable private fun NotesSection(notes: String, onNotesChange: (String) -> Unit) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - OutlinedTextField( - value = notes, - onValueChange = { if (it.length <= 256) onNotesChange(it) }, - label = { Text("Notes") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 4, - supportingText = { Text("${notes.length}/256") } + Column(modifier = Modifier.padding(horizontal = 18.dp)) { + // Serif italic textarea card + Box( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceColor, RoundedCornerShape(10.dp)) + .border(1.dp, BorderSoftColor, RoundedCornerShape(10.dp)) + ) { + BasicTextField( + value = notes, + onValueChange = { if (it.length <= 256) onNotesChange(it) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + lineHeight = 21.sp, + color = FgColor + ), + decorationBox = { innerTextField -> + Box(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + if (notes.isEmpty()) { + Text( + "Write about today…", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + color = FgFaintColor, + lineHeight = 21.sp + ) + } + innerTextField() + } + }, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 78.dp) + ) + } + Text( + text = "${notes.length} / 256", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 0.5.sp, + modifier = Modifier + .align(Alignment.End) + .padding(top = 4.dp) ) } } +// ─── Intimacy section ───────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @Composable private fun IntimacySection( @@ -389,54 +542,44 @@ private fun IntimacySection( onAdd: (String, String?, String?, Boolean) -> Unit, onDelete: (IntimacyLogEntity) -> Unit ) { - var isExpanded by remember { mutableStateOf(true) } var showAddForm by remember { mutableStateOf(false) } - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { isExpanded = !isExpanded } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Intimacy", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - Icon( - if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null + Column(modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)) { + logs.forEach { log -> + IntimacyLogCard( + log = log, + isFemaleInFertileWindow = isFemaleInFertileWindow, + onDelete = { onDelete(log) } ) + Spacer(Modifier.height(8.dp)) } - if (isExpanded) { - logs.forEach { log -> - IntimacyLogCard( - log = log, - isFemaleInFertileWindow = isFemaleInFertileWindow, - onDelete = { onDelete(log) } - ) - } - - TextButton( - onClick = { showAddForm = true }, - modifier = Modifier.padding(top = 4.dp) - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Add encounter") - } + DiaryTextLink( + onClick = { showAddForm = true }, + leading = "+" + ) { + Text( + "Add encounter", + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = AccentColor + ) } } - // Bottom sheet keeps the form above the keyboard and always fully visible if (showAddForm) { ModalBottomSheet( onDismissRequest = { showAddForm = false }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = SurfaceColor, + dragHandle = { + Box( + modifier = Modifier + .padding(top = 12.dp) + .size(width = 36.dp, height = 4.dp) + .background(BorderSoftColor, RoundedCornerShape(2.dp)) + ) + } ) { AddIntimacyForm( profiles = profiles, @@ -457,26 +600,51 @@ private fun IntimacyLogCard( isFemaleInFertileWindow: Boolean, onDelete: () -> Unit ) { - OutlinedCard(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + DiaryCard(accent = true, padding = 12.dp) { Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Text("❤️ · ${log.participantName ?: log.participantType} · ${log.timeOfDay ?: ""} · ${if (log.protected) "Protected" else "Unprotected"}", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = onDelete, modifier = Modifier.size(20.dp)) { - Icon(Icons.Default.Close, contentDescription = "Delete", modifier = Modifier.size(14.dp)) + Text("❤", fontSize = 14.sp, color = AccentColor) + Column(modifier = Modifier.weight(1f)) { + Text( + text = buildString { + append(log.participantName ?: log.participantType) + if (log.timeOfDay != null) append(" · ${log.timeOfDay}") + }, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + color = FgColor + ) + if (isFemaleInFertileWindow && !log.protected) { + Spacer(Modifier.height(3.dp)) + Text( + text = "UNPROTECTED · FERTILE WINDOW", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = OvulationColor, + letterSpacing = 0.4.sp + ) + } else { + Spacer(Modifier.height(3.dp)) + Text( + text = if (log.protected) "PROTECTED" else "UNPROTECTED", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 0.4.sp + ) + } + } + IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Delete", + tint = FgFaintColor, + modifier = Modifier.size(14.dp) + ) } - } - if (isFemaleInFertileWindow && !log.protected) { - Text( - "🌿 Unprotected · Fertile window", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(start = 12.dp, bottom = 8.dp) - ) } } } @@ -502,57 +670,86 @@ private fun AddIntimacyForm( .padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("New encounter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "New encounter", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + color = FgColor + ) // Participant selector - Text("With", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("With", fontFamily = JetBrainsMono, fontSize = 10.sp, color = FgSubtleColor, letterSpacing = 0.5.sp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (otherProfile != null) { - FilterChip( + AubergineFilterChip( selected = participantType == "PARTNER", onClick = { participantType = "PARTNER"; participantName = otherProfile.name }, - label = { Text(otherProfile.name) } + label = otherProfile.name ) } - FilterChip( + AubergineFilterChip( selected = participantType == "OTHER", onClick = { participantType = "OTHER"; participantName = "" }, - label = { Text(if (otherProfile == null) "Add name (optional)" else "Other") } + label = if (otherProfile == null) "Add name (optional)" else "Other" ) } + if (participantType == "OTHER") { OutlinedTextField( value = participantName, onValueChange = { if (it.length <= 32) participantName = it }, - label = { Text("Name (optional)") }, + label = { Text("Name (optional)", fontFamily = InstrumentSans, fontSize = 12.sp) }, singleLine = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = outlinedTextFieldColors() ) } + OutlinedTextField( value = timeOfDay, onValueChange = { timeOfDay = it }, - label = { Text("Time (optional, e.g. 21:30)") }, + label = { Text("Time (optional, e.g. 21:30)", fontFamily = InstrumentSans, fontSize = 12.sp) }, singleLine = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = outlinedTextFieldColors() ) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Column { - Text("Protected", style = MaterialTheme.typography.bodyMedium) + Text("Protected", fontFamily = InstrumentSans, fontSize = 14.sp, color = FgColor) Text( if (isProtected) "Contraception used" else "No contraception", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontFamily = InstrumentSans, + fontSize = 12.sp, + color = FgSubtleColor ) } - Switch(checked = isProtected, onCheckedChange = { isProtected = it }) + Switch( + checked = isProtected, + onCheckedChange = { isProtected = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = AccentOnColor, + checkedTrackColor = AccentColor, + uncheckedThumbColor = FgMutedColor, + uncheckedTrackColor = SurfaceHiColor + ) + ) } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") } + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = FgColor), + border = androidx.compose.foundation.BorderStroke(1.dp, BorderColor) + ) { Text("Cancel", fontFamily = InstrumentSans) } + Button( onClick = { onConfirm( @@ -562,13 +759,29 @@ private fun AddIntimacyForm( isProtected ) }, - modifier = Modifier.weight(1f) - ) { Text("Save encounter") } + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = AccentColor, + contentColor = AccentOnColor + ) + ) { Text("Save encounter", fontFamily = InstrumentSans) } } } } +@Composable +private fun outlinedTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AccentColor, + unfocusedBorderColor = BorderColor, + focusedTextColor = FgColor, + unfocusedTextColor = FgColor, + focusedLabelColor = AccentDimColor, + unfocusedLabelColor = FgSubtleColor, + cursorColor = AccentColor +) + @Composable private fun BackHandler(onBack: () -> Unit) { androidx.activity.compose.BackHandler(onBack = onBack) } + diff --git a/app/src/main/java/com/hsdiary/ui/insights/CycleInsightsScreen.kt b/app/src/main/java/com/hsdiary/ui/insights/CycleInsightsScreen.kt index 14eec3e..45cf7b5 100644 --- a/app/src/main/java/com/hsdiary/ui/insights/CycleInsightsScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/insights/CycleInsightsScreen.kt @@ -1,11 +1,11 @@ package com.hsdiary.ui.insights import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape 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.* import androidx.compose.ui.Alignment @@ -13,15 +13,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.hsdiary.data.model.CyclePhase import com.hsdiary.domain.model.CyclePrediction +import com.hsdiary.ui.components.DiaryCard +import com.hsdiary.ui.components.DiaryTopBar +import com.hsdiary.ui.components.SectionLabel +import com.hsdiary.ui.theme.* import java.time.format.DateTimeFormatter import java.util.Locale +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CycleInsightsScreen( onBack: () -> Unit, @@ -29,179 +39,476 @@ fun CycleInsightsScreen( ) { val state by viewModel.uiState.collectAsState() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Cycle Insights") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") - } - } - ) - } - ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(BgColor) + ) { + DiaryTopBar( + showBack = true, + onBack = onBack, + title = "Cycle insights", + subtitle = if (state.prediction != null) + "${state.prediction!!.cyclesLogged} cycles · Tier ${state.prediction!!.tier} prediction" + else null + ) + if (state.isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + CircularProgressIndicator(color = AccentColor) } - return@Scaffold + return@Column } val prediction = state.prediction if (prediction == null || prediction.cyclesLogged == 0) { Box( - Modifier.fillMaxSize().padding(padding).padding(32.dp), + Modifier + .fillMaxSize() + .padding(32.dp), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("🩸", style = MaterialTheme.typography.displayMedium) + Text("🩸", fontSize = 40.sp) Spacer(Modifier.height(16.dp)) - Text("No cycle data yet", style = MaterialTheme.typography.titleMedium) + Text( + "No cycle data yet", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 22.sp, + color = FgColor + ) Spacer(Modifier.height(8.dp)) Text( "Log your first period on the calendar to begin tracking.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontFamily = InstrumentSans, + fontSize = 14.sp, + color = FgMutedColor, + lineHeight = 20.sp ) } } - return@Scaffold + return@Column } Column( modifier = Modifier .fillMaxSize() - .padding(padding) .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Current phase card - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Current Cycle", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + // ── I. Today ─────────────────────────────────────────────────────── + SectionLabel(title = "Today", num = "I.") + Column(modifier = Modifier.padding(horizontal = 18.dp)) { + DiaryCard { + // Cycle day line + Text( + text = "Cycle day ${prediction.currentCycleDay} of ~${prediction.averageCycleLength}".uppercase(), + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 1.6.sp + ) Spacer(Modifier.height(8.dp)) - val phaseText = when (prediction.currentPhase) { - CyclePhase.MENSTRUATION_CONFIRMED, CyclePhase.MENSTRUATION_PREDICTED -> "🩸 Menstruation" - CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "🌿 Fertile Window" - CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "🌿 Ovulation Day" - CyclePhase.LUTEAL -> "🌙 Luteal Phase" - CyclePhase.FOLLICULAR -> "🌱 Follicular Phase" - else -> "—" + + // Phase name — large serif italic + val phaseName = when (prediction.currentPhase) { + CyclePhase.MENSTRUATION_CONFIRMED, + CyclePhase.MENSTRUATION_PREDICTED -> "Menstruation." + CyclePhase.FERTILE_WINDOW, + CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile window." + CyclePhase.OVULATION, + CyclePhase.OVULATION_PREDICTED -> "Ovulation." + CyclePhase.LUTEAL -> "Luteal phase." + CyclePhase.FOLLICULAR -> "Follicular phase." + else -> "Tracking." } - Text(phaseText, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) - if (prediction.currentCycleDay > 0) { - Text("Day ${prediction.currentCycleDay}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = phaseName, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 30.sp, + lineHeight = 32.sp, + color = FgColor + ) + + // Subtitle + val phaseSubtitle = when (prediction.currentPhase) { + CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> + prediction.nextPeriodDate?.let { + val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault()) + "A single peak day. Fertile window narrowing — next period predicted for ${it.format(fmt)}." + } ?: "A single peak day." + CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> + "Elevated fertility. Track closely." + CyclePhase.MENSTRUATION_CONFIRMED -> + "Period day ${prediction.currentCycleDay}." + else -> prediction.daysUntilNextPeriod?.let { "Next period in ~$it days." } ?: "" } + if (phaseSubtitle.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + text = phaseSubtitle, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + lineHeight = 20.sp, + color = FgMutedColor + ) + } + + // Phase arc + Spacer(Modifier.height(18.dp)) + PhaseArc( + cycleDay = prediction.currentCycleDay, + avgLength = prediction.averageCycleLength + ) } } - // Stats row - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatCard( - label = "Avg Cycle", - value = "${prediction.averageCycleLength} days", - modifier = Modifier.weight(1f) - ) - StatCard( - label = "Cycles Logged", - value = "${prediction.cyclesLogged}", - modifier = Modifier.weight(1f) - ) - StatCard( - label = "Prediction", - value = "Tier ${prediction.tier}", - modifier = Modifier.weight(1f) - ) + // ── II. By the numbers ───────────────────────────────────────────── + SectionLabel(title = "By the numbers", num = "II.") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatTile("Avg cycle", prediction.averageCycleLength.toString(), "days", Modifier.weight(1f)) + StatTile("Logged", prediction.cyclesLogged.toString(), "cycles", Modifier.weight(1f)) + StatTile("Tier", prediction.tier.toString(), "prediction", Modifier.weight(1f)) } - // Next period - prediction.nextPeriodDate?.let { next -> - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Upcoming", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(8.dp)) - val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault()) - Text("Next period: ${next.format(fmt)}", style = MaterialTheme.typography.bodyLarge) - prediction.daysUntilNextPeriod?.let { days -> - Text("In ~$days days", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + // ── III. Upcoming ────────────────────────────────────────────────── + SectionLabel(title = "Upcoming", num = "III.") + Column(modifier = Modifier.padding(horizontal = 18.dp)) { + DiaryCard { + prediction.nextPeriodDate?.let { nextDate -> + val fmt = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column { + Text( + "Next period", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 16.sp, + color = FgColor + ) + prediction.daysUntilNextPeriod?.let { + Spacer(Modifier.height(4.dp)) + Text( + "IN ~$it DAYS", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 1.sp + ) + } + } + Column(horizontalAlignment = Alignment.End) { + Text( + nextDate.format(fmt), + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 18.sp, + color = PeriodColor + ) + Text( + "± 2 DAYS", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 0.6.sp + ) + } } - prediction.fertileWindowStart?.let { fw -> - val fwEnd = prediction.fertileWindowEnd + } + + prediction.fertileWindowStart?.let { fw -> + val fwEnd = prediction.fertileWindowEnd + val fmt = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + color = BorderSoftColor, + thickness = 0.5.dp + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( - "Fertile window: ${fw.format(fmt)} – ${fwEnd?.format(fmt) ?: ""}", - style = MaterialTheme.typography.bodyMedium + "Fertile window", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + color = FgMutedColor + ) + Text( + "${fw.format(fmt)} — ${fwEnd?.format(fmt) ?: "?"}".uppercase(), + fontFamily = JetBrainsMono, + fontSize = 11.sp, + color = FgColor, + letterSpacing = 0.4.sp ) } } } } - // Cycle length bar chart + // ── IV. History ──────────────────────────────────────────────────── if (state.recentCycles.size >= 2) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Cycle History", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(12.dp)) - CycleLengthBarChart(cycles = state.recentCycles.mapNotNull { it.cycleLength }) + SectionLabel(title = "History", num = "IV.") + Column(modifier = Modifier.padding(horizontal = 18.dp)) { + DiaryCard { + val lengths = state.recentCycles.mapNotNull { it.cycleLength } + CycleHistoryChart(lengths = lengths) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + "LAST ${lengths.size} CYCLES", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 0.5.sp + ) + Text( + "AVG · ${lengths.average().toInt()} D", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 0.5.sp + ) + } } } } + + Spacer(Modifier.height(32.dp)) } } } +// ─── Phase arc ──────────────────────────────────────────────────────────────── @Composable -private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { - ElevatedCard(modifier = modifier) { - Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally +private fun PhaseArc(cycleDay: Int, avgLength: Int) { + val total = avgLength.toFloat().coerceAtLeast(1f) + val periodColor = PeriodColor + val lutealColor = LutealColor + val fertileColor = FertileColor + val predColor = PeriodPredictedColor + val ovulColor = OvulationColor + val bgColor = BgColor + + data class Seg(val from: Float, val to: Float, val color: Color, val alpha: Float = 1f) + val segs = listOf( + Seg(0f, 5f / total, periodColor), + Seg(5f / total, 10f / total, lutealColor), + Seg(10f / total, 15f / total, fertileColor), + Seg(15f / total, 23f / total, lutealColor), + Seg(23f / total, 1f, predColor, 0.5f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier.size(160.dp, 140.dp), + contentAlignment = Alignment.Center ) { - Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Canvas(modifier = Modifier.size(160.dp, 140.dp)) { + val cx = size.width / 2f + val cy = size.height * 0.5f + val r = 60.dp.toPx() + val sw = 8.dp.toPx() + + segs.forEach { seg -> + val startAngle = seg.from * 360f - 90f + val sweepAngle = (seg.to - seg.from) * 360f + drawArc( + color = seg.color.copy(alpha = seg.alpha), + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + topLeft = Offset(cx - r, cy - r), + size = Size(r * 2, r * 2), + style = Stroke(width = sw) + ) + } + + // Today marker dot + val angle = (cycleDay.toFloat() / total) * 2f * PI.toFloat() - PI.toFloat() / 2f + val mx = cx + r * cos(angle) + val my = cy + r * sin(angle) + drawCircle(bgColor, 7.dp.toPx(), Offset(mx, my)) + drawCircle(ovulColor, 7.dp.toPx(), Offset(mx, my), style = Stroke(width = 2.dp.toPx())) + } + // Day number overlaid in centre + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = cycleDay.toString(), + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + color = FgColor + ) + Text( + text = "DAY", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 1.sp + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + LegendRow(PeriodColor, "Menstruation") + LegendRow(LutealColor, "Follicular / Luteal") + LegendRow(FertileColor, "Fertile window") + LegendRow(OvulationColor, "Ovulation", today = true) } } } @Composable -private fun CycleLengthBarChart(cycles: List) { - if (cycles.isEmpty()) return - val maxLen = cycles.max().toFloat() - val barColor = MaterialTheme.colorScheme.primary - val avgColor = MaterialTheme.colorScheme.secondary - val avg = cycles.average().toFloat() +private fun LegendRow(color: Color, label: String, today: Boolean = false) { + Row( + modifier = Modifier.padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(color, RoundedCornerShape(2.dp)) + ) + Text( + text = label, + fontFamily = InstrumentSans, + fontSize = 11.sp, + color = if (today) FgColor else FgMutedColor + ) + if (today) { + Text( + text = "· TODAY", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = AccentColor, + letterSpacing = 1.sp + ) + } + } +} + +// ─── Stat tile ──────────────────────────────────────────────────────────────── +@Composable +private fun StatTile(label: String, value: String, unit: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(SurfaceColor, RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 14.dp) + ) { + Text( + text = label.uppercase(), + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 1.2.sp + ) + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = value, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + lineHeight = 22.sp, + color = FgColor + ) + Text( + text = unit, + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgSubtleColor, + letterSpacing = 0.6.sp, + modifier = Modifier.padding(bottom = 3.dp) + ) + } + } +} + +// ─── History bar chart ──────────────────────────────────────────────────────── +@Composable +private fun CycleHistoryChart(lengths: List) { + if (lengths.isEmpty()) return + val maxLen = lengths.max().toFloat() + val avg = lengths.average().toFloat() + val accentBar = AccentColor + val surfaceHi = SurfaceHiColor + BorderSoftColor + val accentFaint = AccentFaintColor Canvas( modifier = Modifier .fillMaxWidth() - .height(100.dp) + .height(110.dp) ) { - val barWidth = (size.width / (cycles.size * 1.5f)) - val spacing = barWidth * 0.5f - cycles.forEachIndexed { i, length -> - val barHeight = (length / maxLen) * size.height * 0.85f - val x = i * (barWidth + spacing) - drawRect( - color = barColor, - topLeft = Offset(x, size.height - barHeight), - size = Size(barWidth, barHeight) - ) - } - // average line - val avgY = size.height - (avg / maxLen) * size.height * 0.85f + val gap = 14.dp.toPx() + val barW = (size.width - gap * (lengths.size - 1)) / lengths.size + + // Average dashed line + val avgY = size.height - (avg / maxLen) * size.height * 0.9f drawLine( - color = avgColor, + color = accentFaint, start = Offset(0f, avgY), end = Offset(size.width, avgY), - strokeWidth = 2.dp.toPx() + strokeWidth = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(6.dp.toPx(), 4.dp.toPx()) + ) ) + + lengths.forEachIndexed { i, length -> + val barH = (length / maxLen) * size.height * 0.9f + val x = i * (barW + gap) + val isLatest = i == lengths.lastIndex + drawRect( + color = if (isLatest) accentBar else surfaceHi, + topLeft = Offset(x, size.height - barH), + size = Size(barW, barH) + ) + } } + + // Numeric labels below bars Spacer(Modifier.height(4.dp)) - Text( - "Last ${cycles.size} cycles · Average: ${avg.toInt()} days", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row(modifier = Modifier.fillMaxWidth()) { + lengths.forEachIndexed { i, length -> + Text( + text = length.toString(), + modifier = Modifier.weight(1f), + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = if (i == lengths.lastIndex) AccentColor else FgFaintColor, + letterSpacing = 0.4.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } } + diff --git a/app/src/main/java/com/hsdiary/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/hsdiary/ui/onboarding/OnboardingScreen.kt index 8155842..0a6dec2 100644 --- a/app/src/main/java/com/hsdiary/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/onboarding/OnboardingScreen.kt @@ -12,12 +12,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.hsdiary.data.model.ProfileType -import com.hsdiary.ui.theme.AvatarColors +import com.hsdiary.ui.calendar.parseColor +import com.hsdiary.ui.theme.* @Composable fun OnboardingScreen( @@ -26,64 +32,208 @@ fun OnboardingScreen( ) { val state by viewModel.uiState.collectAsState() - when (state.step) { - 0 -> WelcomeStep(onNext = { viewModel.nextStep() }) - 1 -> ProfileSetupStep( - title = "Create your profile", - name = state.profile1Name, - colorIndex = state.profile1ColorIndex, - profileType = state.profile1Type, - onNameChange = viewModel::updateProfile1Name, - onColorChange = viewModel::updateProfile1Color, - onTypeChange = viewModel::updateProfile1Type, - onNext = { viewModel.nextStep() }, - canSkip = false - ) - 2 -> ProfileSetupStep( - title = "Add a second profile", - subtitle = "Optional — can be added later in Settings", - name = state.profile2Name, - colorIndex = state.profile2ColorIndex, - profileType = state.profile2Type, - onNameChange = viewModel::updateProfile2Name, - onColorChange = viewModel::updateProfile2Color, - onTypeChange = viewModel::updateProfile2Type, - onNext = { viewModel.completeOnboarding(onComplete) }, - canSkip = true, - onSkip = { viewModel.completeOnboarding(onComplete) }, - isLoading = state.isLoading - ) - } -} - -@Composable -private fun WelcomeStep(onNext: () -> Unit) { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = Modifier + .fillMaxSize() + .background(BgColor) ) { - Text("🩺", style = MaterialTheme.typography.displayLarge) - Spacer(Modifier.height(24.dp)) - Text( - text = "H&S Diary", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold - ) - Spacer(Modifier.height(12.dp)) - Text( - text = "Your private health & cycle tracker.\nAll data stays on your device.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(48.dp)) - Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { - Text("Get Started") + when (state.step) { + 0 -> WelcomeStep(onNext = { viewModel.nextStep() }) + 1 -> ProfileSetupStep( + title = "Create your profile", + name = state.profile1Name, + colorIndex = state.profile1ColorIndex, + profileType = state.profile1Type, + onNameChange = viewModel::updateProfile1Name, + onColorChange = viewModel::updateProfile1Color, + onTypeChange = viewModel::updateProfile1Type, + onNext = { viewModel.nextStep() }, + canSkip = false + ) + 2 -> ProfileSetupStep( + title = "Add a second profile", + subtitle = "Optional — can be added later in Settings", + name = state.profile2Name, + colorIndex = state.profile2ColorIndex, + profileType = state.profile2Type, + onNameChange = viewModel::updateProfile2Name, + onColorChange = viewModel::updateProfile2Color, + onTypeChange = viewModel::updateProfile2Type, + onNext = { viewModel.completeOnboarding(onComplete) }, + canSkip = true, + onSkip = { viewModel.completeOnboarding(onComplete) }, + isLoading = state.isLoading + ) } } } +// ─── Welcome step ───────────────────────────────────────────────────────────── +@Composable +private fun WelcomeStep(onNext: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // "VOLUME ONE · ENTRY 01" monogram header + Text( + text = "VOLUME ONE · ENTRY 01", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 3.sp, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(24.dp)) + + // H&S — large italic serif + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = FgColor)) { append("H") } + withStyle(SpanStyle(color = FgColor.copy(alpha = 0.45f))) { append("&") } + withStyle(SpanStyle(color = FgColor)) { append("S") } + }, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 44.sp, + lineHeight = 44.sp, + textAlign = TextAlign.Center, + letterSpacing = (-0.5).sp + ) + Text( + text = "Diary", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 44.sp, + lineHeight = 45.sp, + color = FgColor, + textAlign = TextAlign.Center, + letterSpacing = (-0.5).sp + ) + + Spacer(Modifier.height(18.dp)) + + // Tagline + Text( + text = "A quiet ledger for your body —\nsymptoms, cycle, and the days between.", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 15.sp, + lineHeight = 22.sp, + color = FgMutedColor, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(28.dp)) + + // Ornament divider + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = BorderSoftColor, + thickness = 0.5.dp + ) + Text( + text = "✦", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + color = AccentColor + ) + HorizontalDivider( + modifier = Modifier.weight(1f), + color = BorderSoftColor, + thickness = 0.5.dp + ) + } + + Spacer(Modifier.height(28.dp)) + + // Feature list + val features = listOf( + "❍" to "Track period, fertility & ovulation", + "❍" to "Log symptoms, mood, intimacy", + "❍" to "Stays on your device. No cloud.", + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + features.forEach { (bullet, label) -> + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = bullet, + fontFamily = JetBrainsMono, + fontSize = 12.sp, + color = AccentColor, + modifier = Modifier.padding(top = 2.dp) + ) + Text( + text = label, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + lineHeight = 20.sp, + color = FgColor + ) + } + } + } + + Spacer(Modifier.height(40.dp)) + + // Primary button + Box( + modifier = Modifier + .fillMaxWidth() + .background(AccentColor, RoundedCornerShape(999.dp)) + .clickable(onClick = onNext) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Begin writing", + fontFamily = InstrumentSans, + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + letterSpacing = 0.1.sp, + color = AccentOnColor + ) + } + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onNext) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + "I have an existing diary", + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = FgMutedColor + ) + } + } +} + +// ─── Profile setup step ─────────────────────────────────────────────────────── @Composable private fun ProfileSetupStep( title: String, @@ -100,36 +250,84 @@ private fun ProfileSetupStep( isLoading: Boolean = false ) { Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 48.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 48.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + title, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 28.sp, + color = FgColor + ) if (subtitle != null) { - Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + subtitle, + fontFamily = InstrumentSans, + fontSize = 14.sp, + color = FgMutedColor + ) } OutlinedTextField( value = name, onValueChange = { if (it.length <= 32) onNameChange(it) }, - label = { Text("Name") }, + label = { Text("Name", fontFamily = InstrumentSans, fontSize = 12.sp) }, singleLine = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AccentColor, + unfocusedBorderColor = BorderColor, + focusedTextColor = FgColor, + unfocusedTextColor = FgColor, + focusedLabelColor = AccentDimColor, + unfocusedLabelColor = FgSubtleColor, + cursorColor = AccentColor + ) ) - // Profile type selector - Text("Profile type", style = MaterialTheme.typography.labelLarge) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // Profile type + Text( + "PROFILE TYPE", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 1.sp + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ProfileType.values().forEach { type -> - FilterChip( - selected = profileType == type, - onClick = { onTypeChange(type) }, - label = { Text(if (type == ProfileType.FEMALE) "Female" else "Male") } - ) + val sel = profileType == type + Box( + modifier = Modifier + .background( + if (sel) AccentFaintColor else Color.Transparent, + RoundedCornerShape(999.dp) + ) + .border(1.dp, if (sel) AccentColor else BorderColor, RoundedCornerShape(999.dp)) + .clickable { onTypeChange(type) } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = if (type == ProfileType.FEMALE) "Female" else "Male", + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = if (sel) FgColor else FgMutedColor + ) + } } } - // Color selector - Text("Avatar color", style = MaterialTheme.typography.labelLarge) + // Avatar color + Text( + "AVATAR COLOUR", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 1.sp + ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { AvatarColors.forEachIndexed { idx, color -> Box( @@ -138,7 +336,8 @@ private fun ProfileSetupStep( .clip(CircleShape) .background(color) .then( - if (idx == colorIndex) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + if (idx == colorIndex) + Modifier.border(2.5.dp, FgColor, CircleShape) else Modifier ) .clickable { onColorChange(idx) } @@ -148,17 +347,48 @@ private fun ProfileSetupStep( Spacer(Modifier.weight(1f)) - Button( - onClick = onNext, - enabled = !isLoading && (name.isNotBlank() || canSkip), - modifier = Modifier.fillMaxWidth() + Box( + modifier = Modifier + .fillMaxWidth() + .background( + if (!isLoading && (name.isNotBlank() || canSkip)) AccentColor else AccentDimColor, + RoundedCornerShape(999.dp) + ) + .clickable(enabled = !isLoading && (name.isNotBlank() || canSkip), onClick = onNext) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center ) { - if (isLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - else Text(if (canSkip && name.isBlank()) "Skip" else "Continue") + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = AccentOnColor + ) + } else { + Text( + text = if (canSkip && name.isBlank()) "Skip" else "Continue", + fontFamily = InstrumentSans, + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + color = AccentOnColor + ) + } } + if (canSkip && name.isNotBlank()) { - TextButton(onClick = { onSkip?.invoke() }, modifier = Modifier.fillMaxWidth()) { - Text("Skip for now") + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onSkip?.invoke() } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Skip for now", + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = FgMutedColor + ) } } } diff --git a/app/src/main/java/com/hsdiary/ui/settings/SettingsScreen.kt b/app/src/main/java/com/hsdiary/ui/settings/SettingsScreen.kt index 2697c35..252e23c 100644 --- a/app/src/main/java/com/hsdiary/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/settings/SettingsScreen.kt @@ -6,28 +6,31 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape 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.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.hsdiary.data.db.entity.ProfileEntity import com.hsdiary.ui.calendar.parseColor -import com.hsdiary.ui.theme.AvatarColors +import com.hsdiary.ui.components.AvatarDot +import com.hsdiary.ui.components.DiaryTopBar +import com.hsdiary.ui.components.SectionLabel +import com.hsdiary.ui.theme.* private val colorHexes = listOf( - "#E91E63","#9C27B0","#2196F3","#009688", - "#4CAF50","#FF9800","#FF4081","#7C4DFF" + "#E38973", "#9C6BD0", "#5090D0", "#40A898", + "#68A868", "#D09040", "#D07090", "#8A7AE8" ) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( onBack: () -> Unit, @@ -37,96 +40,90 @@ fun SettingsScreen( var clearProfileTarget by remember { mutableStateOf(null) } var showClearAll by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") - } - } - ) - } - ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(BgColor) + ) { + DiaryTopBar(showBack = true, onBack = onBack, title = "Settings") + Column( modifier = Modifier .fillMaxSize() - .padding(padding) .verticalScroll(rememberScrollState()) ) { - // App settings - SettingsSection("App") { - // First day of week - ListItem( - headlineContent = { Text("First day of week") }, - trailingContent = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = state.firstDayOfWeek == 1, - onClick = { viewModel.setFirstDayOfWeek(1) }, - label = { Text("Sunday") } - ) - FilterChip( - selected = state.firstDayOfWeek == 2, - onClick = { viewModel.setFirstDayOfWeek(2) }, - label = { Text("Monday") } - ) - } - } - ) - HorizontalDivider() - // Theme - ListItem( - headlineContent = { Text("App theme") }, - trailingContent = { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - listOf("Light", "Dark", "System").forEach { theme -> - val key = theme.uppercase() - FilterChip( - selected = state.appTheme == key, - onClick = { viewModel.setAppTheme(key) }, - label = { Text(theme) } - ) - } - } - } - ) - } - - // Profile settings - state.profiles.forEach { profile -> - SettingsSection("Profile: ${profile.name}") { - ProfileSettingsCard( - profile = profile, - onNameChange = { viewModel.updateProfileName(profile.id, it) }, - onColorChange = { viewModel.updateProfileColor(profile.id, it) } + // ── I. Preferences ──────────────────────────────────────────────── + SectionLabel(title = "Preferences", num = "I.") + DiarySettingsCard(modifier = Modifier.padding(horizontal = 18.dp)) { + AubergineSettingRow(label = "First day of week") { + AubergineTabs( + options = listOf("SUN" to 1, "MON" to 2), + selectedKey = state.firstDayOfWeek, + onSelect = viewModel::setFirstDayOfWeek ) - HorizontalDivider() - ListItem( - headlineContent = { Text("Clear profile data", color = MaterialTheme.colorScheme.error) }, - supportingContent = { Text("Removes all logs for this profile") }, - modifier = Modifier.clickable { clearProfileTarget = profile.id } + } + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) + AubergineSettingRow(label = "App theme") { + AubergineTabs( + options = listOf("LIGHT" to "LIGHT", "DARK" to "DARK", "AUTO" to "SYSTEM"), + selectedKey = state.appTheme, + onSelect = viewModel::setAppTheme ) } } - // Data management - SettingsSection("Data") { - ListItem( - headlineContent = { Text("Clear all data", color = MaterialTheme.colorScheme.error) }, - supportingContent = { Text("Removes all profiles and data — cannot be undone") }, - modifier = Modifier.clickable { showClearAll = true } + // ── II. Profiles ────────────────────────────────────────────────── + SectionLabel(title = "Profiles", num = "II.") + Column(modifier = Modifier.padding(horizontal = 18.dp)) { + state.profiles.forEach { profile -> + ProfileRow( + profile = profile, + onNameChange = { viewModel.updateProfileName(profile.id, it) }, + onColorChange = { viewModel.updateProfileColor(profile.id, it) }, + onClearData = { clearProfileTarget = profile.id } + ) + Spacer(Modifier.height(8.dp)) + } + } + + // ── III. Data ───────────────────────────────────────────────────── + SectionLabel(title = "Data", num = "III.") + DiarySettingsCard(modifier = Modifier.padding(horizontal = 18.dp)) { + AubergineSettingRow(label = "Export as JSON", trailing = "›") + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) + AubergineSettingRow( + label = "Clear all data", + labelColor = Color(0xFFD06060), + trailing = "›", + onClick = { showClearAll = true } ) } - Spacer(Modifier.height(32.dp)) - Text( - "H&S Diary · All data stored locally on this device.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) + // ── Colophon ────────────────────────────────────────────────────── + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "H&S Diary — kept privately,\non this device alone.", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 13.sp, + lineHeight = 19.sp, + color = FgSubtleColor, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(Modifier.height(10.dp)) + Text( + text = "v 2.6.0", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 2.sp + ) + } } } @@ -135,106 +132,299 @@ fun SettingsScreen( val profile = state.profiles.find { it.id == profileId } AlertDialog( onDismissRequest = { clearProfileTarget = null }, - title = { Text("Clear data?") }, - text = { Text("This will remove all logs for ${profile?.name}. This cannot be undone.") }, + containerColor = SurfaceColor, + title = { + Text( + "Clear data?", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 20.sp, + color = FgColor + ) + }, + text = { + Text( + "This will remove all logs for ${profile?.name}. This cannot be undone.", + fontFamily = InstrumentSans, + fontSize = 14.sp, + color = FgMutedColor + ) + }, confirmButton = { - TextButton(onClick = { - viewModel.clearProfileData(profileId) - clearProfileTarget = null - }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { - Text("Clear") - } + TextButton( + onClick = { viewModel.clearProfileData(profileId); clearProfileTarget = null }, + colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFD06060)) + ) { Text("Clear", fontFamily = InstrumentSans) } }, dismissButton = { - TextButton(onClick = { clearProfileTarget = null }) { Text("Cancel") } + TextButton( + onClick = { clearProfileTarget = null }, + colors = ButtonDefaults.textButtonColors(contentColor = FgMutedColor) + ) { Text("Cancel", fontFamily = InstrumentSans) } } ) } - // Confirm clear all if (showClearAll) { AlertDialog( onDismissRequest = { showClearAll = false }, - title = { Text("Clear all data?") }, - text = { Text("This will remove all profiles, logs, and reset the app. This CANNOT be undone.") }, + containerColor = SurfaceColor, + title = { + Text( + "Clear all data?", + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 20.sp, + color = FgColor + ) + }, + text = { + Text( + "This will remove all profiles, logs, and reset the app. This CANNOT be undone.", + fontFamily = InstrumentSans, + fontSize = 14.sp, + color = FgMutedColor + ) + }, confirmButton = { TextButton( onClick = { viewModel.clearAllData(); showClearAll = false }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { Text("Clear Everything") } + colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFD06060)) + ) { Text("Clear Everything", fontFamily = InstrumentSans) } }, dismissButton = { - TextButton(onClick = { showClearAll = false }) { Text("Cancel") } + TextButton( + onClick = { showClearAll = false }, + colors = ButtonDefaults.textButtonColors(contentColor = FgMutedColor) + ) { Text("Cancel", fontFamily = InstrumentSans) } } ) } } +// ─── Profile row card ───────────────────────────────────────────────────────── @Composable -private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { - Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - Text( - title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - ) { - Column { content() } - } - } -} - -@Composable -private fun ProfileSettingsCard( +private fun ProfileRow( profile: ProfileEntity, onNameChange: (String) -> Unit, - onColorChange: (String) -> Unit + onColorChange: (String) -> Unit, + onClearData: () -> Unit ) { + var nameEditMode by remember(profile.id) { mutableStateOf(false) } var editingName by remember(profile.id) { mutableStateOf(profile.name) } - var nameEditMode by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } - Column(modifier = Modifier.padding(16.dp)) { - // Name - if (nameEditMode) { - OutlinedTextField( - value = editingName, - onValueChange = { if (it.length <= 32) editingName = it }, - label = { Text("Name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - TextButton(onClick = { - onNameChange(editingName) - nameEditMode = false - }) { Text("Save") } + Box( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceColor, RoundedCornerShape(14.dp)) + .border(1.dp, BorderSoftColor, RoundedCornerShape(14.dp)) + .padding(14.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AvatarDot( + name = profile.name, + avatarColor = parseColor(profile.avatarColor), + size = 32 + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.name, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 16.sp, + color = FgColor + ) + Text( + text = profile.profileType.uppercase(), + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgFaintColor, + letterSpacing = 1.sp + ) } - ) - } else { - ListItem( - headlineContent = { Text(profile.name) }, - supportingContent = { Text("Tap to edit name") }, - modifier = Modifier.clickable { nameEditMode = true } - ) - } + Text( + text = if (expanded) "−" else "›", + fontFamily = JetBrainsMono, + fontSize = 14.sp, + color = FgFaintColor + ) + } - Spacer(Modifier.height(8.dp)) - Text("Avatar color", style = MaterialTheme.typography.labelMedium) - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 4.dp)) { - colorHexes.forEachIndexed { idx, hex -> - val color = parseColor(hex) - val isSelected = profile.avatarColor == hex - Box( + if (expanded) { + Spacer(Modifier.height(12.dp)) + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) + Spacer(Modifier.height(12.dp)) + + // Name edit + if (nameEditMode) { + OutlinedTextField( + value = editingName, + onValueChange = { if (it.length <= 32) editingName = it }, + label = { Text("Name", fontFamily = InstrumentSans, fontSize = 12.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = AccentColor, + unfocusedBorderColor = BorderColor, + focusedTextColor = FgColor, + unfocusedTextColor = FgColor, + focusedLabelColor = AccentDimColor, + unfocusedLabelColor = FgSubtleColor + ), + trailingIcon = { + TextButton(onClick = { + onNameChange(editingName) + nameEditMode = false + }) { + Text("Save", fontFamily = InstrumentSans, color = AccentColor) + } + } + ) + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { nameEditMode = true } + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Name", fontFamily = InstrumentSans, fontSize = 13.sp, color = FgColor) + Text(profile.name, fontFamily = InstrumentSans, fontSize = 13.sp, color = FgMutedColor) + } + } + + Spacer(Modifier.height(12.dp)) + Text( + "AVATAR COLOUR", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgFaintColor, + letterSpacing = 1.sp + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + colorHexes.forEach { hex -> + val color = parseColor(hex) + val isSelected = profile.avatarColor == hex + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .then( + if (isSelected) + Modifier.border(2.dp, FgColor, CircleShape) + else Modifier + ) + .clickable { onColorChange(hex) } + ) + } + } + + Spacer(Modifier.height(12.dp)) + Text( + text = "Clear profile data", + fontFamily = InstrumentSans, + fontSize = 13.sp, + color = Color(0xFFD06060), modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(color) - .then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier) - .clickable { onColorChange(hex) } + .clickable(onClick = onClearData) + .padding(vertical = 4.dp) + ) + } + } + } +} + +// ─── Helper composables ─────────────────────────────────────────────────────── +@Composable +private fun DiarySettingsCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(SurfaceColor, RoundedCornerShape(14.dp)) + .border(1.dp, BorderSoftColor, RoundedCornerShape(14.dp)) + ) { + Column(content = content) + } +} + +@Composable +private fun AubergineSettingRow( + label: String, + labelColor: Color = FgColor, + hint: String? = null, + trailing: String? = null, + onClick: (() -> Unit)? = null, + control: (@Composable () -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text(label, fontFamily = InstrumentSans, fontSize = 13.sp, color = labelColor) + if (hint != null) { + Text(hint, fontFamily = InstrumentSans, fontSize = 11.sp, color = FgSubtleColor) + } + } + control?.invoke() + if (trailing != null) { + Text( + text = trailing, + fontFamily = CormorantGaramond, + fontSize = 14.sp, + color = FgFaintColor + ) + } + } +} + +@Composable +private fun AubergineTabs( + options: List>, + selectedKey: T, + onSelect: (T) -> Unit +) { + Row( + modifier = Modifier + .background(SurfaceLoColor, RoundedCornerShape(999.dp)) + .border(1.dp, BorderSoftColor, RoundedCornerShape(999.dp)) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + options.forEach { (label, key) -> + val sel = key == selectedKey + Box( + modifier = Modifier + .background( + if (sel) SurfaceHiColor else Color.Transparent, + RoundedCornerShape(999.dp) + ) + .clickable { onSelect(key) } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = label, + fontFamily = JetBrainsMono, + fontSize = 11.sp, + letterSpacing = 0.2.sp, + color = if (sel) FgColor else FgSubtleColor ) } } diff --git a/app/src/main/java/com/hsdiary/ui/theme/Color.kt b/app/src/main/java/com/hsdiary/ui/theme/Color.kt index 89d7eae..72c8896 100644 --- a/app/src/main/java/com/hsdiary/ui/theme/Color.kt +++ b/app/src/main/java/com/hsdiary/ui/theme/Color.kt @@ -2,30 +2,52 @@ package com.hsdiary.ui.theme import androidx.compose.ui.graphics.Color -// Material theme seeds -val PrimaryPink = Color(0xFFE91E63) -val PrimaryPinkDark = Color(0xFFC2185B) -val OnPrimary = Color(0xFFFFFFFF) +// ─── Aubergine Nocturne — base surfaces ───────────────────────────────────── +// All values converted from the design's OKLCH tokens to sRGB. +val BgColor = Color(0xFF1C1015) // oklch(0.16 0.012 340) +val SurfaceColor = Color(0xFF2B1924) // oklch(0.21 0.018 340) +val SurfaceHiColor = Color(0xFF381F30) // oklch(0.26 0.022 340) +val SurfaceLoColor = Color(0xFF150B0F) // oklch(0.13 0.012 340) +val BorderColor = Color(0xFF4A2838) // oklch(0.30 0.022 340) +val BorderSoftColor = Color(0xFF3A1E2C) // oklch(0.25 0.018 340) -// Cycle phase colors -val PeriodColor = Color(0xFFB71C1C) -val PeriodPredictedColor = Color(0xFFEF9A9A) -val FertileColor = Color(0xFF00796B) -val FertilePredictedColor = Color(0xFF80CBC4) -val OvulationColor = Color(0xFF00897B) -val LutealColor = Color(0xFFF57F17) -val FollicularColor = Color(0xFFE0E0E0) +// ─── Text — warm cream scale ───────────────────────────────────────────────── +val FgColor = Color(0xFFF1ECE4) // oklch(0.945 0.012 80) +val FgMutedColor = Color(0xFFA49890) // oklch(0.72 0.018 60) +val FgSubtleColor = Color(0xFF7A6A5E) // oklch(0.55 0.018 50) +val FgFaintColor = Color(0xFF594842) // oklch(0.40 0.015 50) -// Avatar palette (8 swatches) +// ─── Accent — Copper Rose (hue 35) ────────────────────────────────────────── +val AccentColor = Color(0xFFE38973) // oklch(0.72 0.115 35) +val AccentDimColor = Color(0xFFAA6050) // oklch(0.58 0.09 35) +val AccentFaintColor = Color(0xFF5A3028) // oklch(0.34 0.04 35) +val AccentOnColor = Color(0xFF261510) // oklch(0.16 0.02 35) + +// ─── Cycle phase colors ────────────────────────────────────────────────────── +val PeriodColor = Color(0xFFC74A4D) // oklch(0.58 0.16 22) confirmed +val PeriodPredictedColor = Color(0xFF7A3030) // oklch(0.42 0.085 22) predicted +val FertileColor = Color(0xFF3A8060) // oklch(0.62 0.08 165) +val FertilePredictedColor = Color(0xFF285040) // oklch(0.50 0.05 165) +val OvulationColor = Color(0xFF65D197) // oklch(0.78 0.13 158) bright peak teal +val LutealColor = Color(0xFFB89030) // oklch(0.70 0.10 70) warm gold +val FollicularColor = Color(0xFFB89030) // same as luteal per UX spec + +// ─── Avatar palette (8 swatches) ───────────────────────────────────────────── val AvatarColors = listOf( - Color(0xFFE91E63), // Rose - Color(0xFF9C27B0), // Purple - Color(0xFF2196F3), // Blue - Color(0xFF009688), // Teal - Color(0xFF4CAF50), // Green - Color(0xFFFF9800), // Orange - Color(0xFFFF4081), // Pink accent - Color(0xFF7C4DFF) // Violet + AccentColor, // Copper Rose + Color(0xFF9C6BD0), // Purple + Color(0xFF5090D0), // Blue + Color(0xFF40A898), // Teal + Color(0xFF68A868), // Green + Color(0xFFD09040), // Orange + Color(0xFFD07090), // Pink + Color(0xFF8A7AE8), // Violet +) +val AvatarColorLabels = listOf( + "Rose", "Purple", "Blue", "Teal", "Green", "Orange", "Pink", "Violet" ) -val AvatarColorLabels = listOf("Rose", "Purple", "Blue", "Teal", "Green", "Orange", "Pink", "Violet") +// ─── Legacy aliases kept for code that hasn't been updated yet ─────────────── +val PrimaryPink = AccentColor +val PrimaryPinkDark = AccentDimColor +val OnPrimary = AccentOnColor diff --git a/app/src/main/java/com/hsdiary/ui/theme/Theme.kt b/app/src/main/java/com/hsdiary/ui/theme/Theme.kt index 89465d6..8a6b63a 100644 --- a/app/src/main/java/com/hsdiary/ui/theme/Theme.kt +++ b/app/src/main/java/com/hsdiary/ui/theme/Theme.kt @@ -1,56 +1,62 @@ package com.hsdiary.ui.theme -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -private val LightColors = lightColorScheme( - primary = PrimaryPink, - onPrimary = OnPrimary, - primaryContainer = Color(0xFFFCE4EC), - onPrimaryContainer = Color(0xFF880E4F), - secondary = Color(0xFF009688), - onSecondary = OnPrimary, - secondaryContainer = Color(0xFFE0F2F1), - onSecondaryContainer = Color(0xFF004D40), - surface = Color(0xFFFFFBFE), - onSurface = Color(0xFF1C1B1F), - surfaceVariant = Color(0xFFF5F5F5), - onSurfaceVariant = Color(0xFF49454F), - outline = Color(0xFF79747E) -) - -private val DarkColors = darkColorScheme( - primary = Color(0xFFF48FB1), - onPrimary = Color(0xFF880E4F), - primaryContainer = Color(0xFFAD1457), - onPrimaryContainer = Color(0xFFFCE4EC), - secondary = Color(0xFF80CBC4), - onSecondary = Color(0xFF004D40), - surface = Color(0xFF1C1B1F), - onSurface = Color(0xFFE6E1E5) +// ─── Aubergine Nocturne — fixed dark colour scheme ─────────────────────────── +// We never use dynamic colour; the Aubergine Nocturne palette is the identity +// of the app, and light/dynamic modes would destroy it. +private val AubergineNocturne = darkColorScheme( + // Primary — Copper Rose accent + primary = AccentColor, + onPrimary = AccentOnColor, + primaryContainer = AccentFaintColor, + onPrimaryContainer = FgColor, + // Secondary — warm gold (luteal phase) + secondary = LutealColor, + onSecondary = AccentOnColor, + secondaryContainer = Color(0xFF3A2800), + onSecondaryContainer = FgColor, + // Tertiary — teal (ovulation / fertile) + tertiary = OvulationColor, + onTertiary = Color(0xFF00301C), + tertiaryContainer = Color(0xFF003828), + onTertiaryContainer = FgColor, + // Error + error = PeriodColor, + onError = FgColor, + errorContainer = Color(0xFF3A1010), + onErrorContainer = FgColor, + // Background & surfaces + background = BgColor, + onBackground = FgColor, + surface = SurfaceColor, + onSurface = FgColor, + surfaceVariant = SurfaceHiColor, + onSurfaceVariant = FgMutedColor, + surfaceTint = AccentColor, + // Outlines + outline = BorderColor, + outlineVariant = BorderSoftColor, + // Inverse (not commonly used) + inverseSurface = FgColor, + inverseOnSurface = BgColor, + inversePrimary = AccentDimColor, + // Scrim for modal sheets + scrim = Color(0xFF120810), ) @Composable -fun HSDiaryTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colorScheme = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColors - else -> LightColors +fun HSDiaryTheme(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalDiaryTypography provides DiaryTypography() + ) { + MaterialTheme( + colorScheme = AubergineNocturne, + typography = Typography, + content = content, + ) } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) } diff --git a/app/src/main/java/com/hsdiary/ui/theme/Type.kt b/app/src/main/java/com/hsdiary/ui/theme/Type.kt index 4a937c3..93bfa06 100644 --- a/app/src/main/java/com/hsdiary/ui/theme/Type.kt +++ b/app/src/main/java/com/hsdiary/ui/theme/Type.kt @@ -1,15 +1,101 @@ package com.hsdiary.ui.theme import androidx.compose.material3.Typography +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont import androidx.compose.ui.unit.sp +import com.hsdiary.R -val Typography = Typography( - bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp), - bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp), - bodySmall = TextStyle(fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp), - titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp), - titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp), - labelSmall = TextStyle(fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp) +private val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs ) + +// ─── Cormorant Garamond — editorial display serif ──────────────────────────── +val CormorantGaramond = FontFamily( + Font(GoogleFont("Cormorant Garamond"), provider, weight = FontWeight.Normal), + Font(GoogleFont("Cormorant Garamond"), provider, weight = FontWeight.Normal, style = FontStyle.Italic), + Font(GoogleFont("Cormorant Garamond"), provider, weight = FontWeight.Medium), + Font(GoogleFont("Cormorant Garamond"), provider, weight = FontWeight.Medium, style = FontStyle.Italic), + Font(GoogleFont("Cormorant Garamond"), provider, weight = FontWeight.SemiBold), +) + +// ─── Instrument Sans — clean UI sans ──────────────────────────────────────── +val InstrumentSans = FontFamily( + Font(GoogleFont("Instrument Sans"), provider, weight = FontWeight.Normal), + Font(GoogleFont("Instrument Sans"), provider, weight = FontWeight.Medium), + Font(GoogleFont("Instrument Sans"), provider, weight = FontWeight.SemiBold), + Font(GoogleFont("Instrument Sans"), provider, weight = FontWeight.Bold), +) + +// ─── JetBrains Mono — mono for labels & numbers ────────────────────────────── +val JetBrainsMono = FontFamily( + Font(GoogleFont("JetBrains Mono"), provider, weight = FontWeight.Normal), + Font(GoogleFont("JetBrains Mono"), provider, weight = FontWeight.Medium), +) + +// ─── Material3 Typography — wired to the three families ───────────────────── +val Typography = Typography( + // Display / hero — Cormorant Garamond italic + displayLarge = TextStyle(fontFamily = CormorantGaramond, fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, fontSize = 44.sp, lineHeight = 48.sp), + displayMedium = TextStyle(fontFamily = CormorantGaramond, fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, fontSize = 30.sp, lineHeight = 36.sp), + displaySmall = TextStyle(fontFamily = CormorantGaramond, fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, fontSize = 22.sp, lineHeight = 28.sp), + // Headlines — Cormorant semi-bold + headlineLarge = TextStyle(fontFamily = CormorantGaramond, fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, lineHeight = 28.sp), + headlineMedium = TextStyle(fontFamily = CormorantGaramond, fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, fontSize = 18.sp, lineHeight = 24.sp), + headlineSmall = TextStyle(fontFamily = CormorantGaramond, fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 22.sp), + // Titles — Instrument Sans + titleLarge = TextStyle(fontFamily = InstrumentSans, fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, lineHeight = 28.sp), + titleMedium = TextStyle(fontFamily = InstrumentSans, fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, lineHeight = 24.sp), + titleSmall = TextStyle(fontFamily = InstrumentSans, fontWeight = FontWeight.Medium, + fontSize = 14.sp, lineHeight = 20.sp), + // Body — Instrument Sans + bodyLarge = TextStyle(fontFamily = InstrumentSans, fontWeight = FontWeight.Normal, + fontSize = 16.sp, lineHeight = 24.sp), + bodyMedium = TextStyle(fontFamily = InstrumentSans, fontWeight = FontWeight.Normal, + fontSize = 14.sp, lineHeight = 20.sp), + bodySmall = TextStyle(fontFamily = InstrumentSans, fontWeight = FontWeight.Normal, + fontSize = 12.sp, lineHeight = 16.sp), + // Labels — JetBrains Mono for that journal-ledger feel + labelLarge = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Medium, + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.05.sp), + labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, + fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.08.sp), + labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, + fontSize = 10.sp, lineHeight = 13.sp, letterSpacing = 0.10.sp), +) + +// ─── Extra text-style tokens accessible anywhere ───────────────────────────── +data class DiaryTypography( + /** Italic serif — used for phrasings, phase labels, card descriptions */ + val displayItalic: TextStyle = TextStyle( + fontFamily = CormorantGaramond, fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 22.sp + ), + /** Mono caps — used for date stamps, section numbers, status lines */ + val monoLabel: TextStyle = TextStyle( + fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, + fontSize = 10.sp, lineHeight = 14.sp, letterSpacing = 1.5.sp + ), + /** Mono number — large cycle/stat digits */ + val monoNumber: TextStyle = TextStyle( + fontFamily = JetBrainsMono, fontWeight = FontWeight.Medium, + fontSize = 22.sp, lineHeight = 28.sp + ), +) + +val LocalDiaryTypography = staticCompositionLocalOf { DiaryTypography() } diff --git a/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt b/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt index 4e19c88..e1f496f 100644 --- a/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt @@ -1,23 +1,25 @@ package com.hsdiary.ui.trends -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import com.hsdiary.ui.components.DiaryTopBar +import com.hsdiary.ui.components.SectionLabel +import com.hsdiary.ui.theme.* -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HealthTrendsScreen( onBack: () -> Unit, @@ -25,83 +27,109 @@ fun HealthTrendsScreen( ) { val state by viewModel.uiState.collectAsState() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Health Trends") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") - } - } - ) - } - ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(BgColor) + ) { + DiaryTopBar( + showBack = true, + onBack = onBack, + title = "Trends", + subtitle = when (state.range) { + TrendsRange.DAYS_30 -> "last 30 days" + TrendsRange.MONTHS_3 -> "last 90 days" + TrendsRange.MONTHS_6 -> "last 6 months" + TrendsRange.ALL -> "all time" + } + ) + if (state.isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + CircularProgressIndicator(color = AccentColor) } - return@Scaffold + return@Column } LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp) ) { - // Range selector + // Range switcher item { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { TrendsRange.entries.forEach { range -> - FilterChip( - selected = state.range == range, - onClick = { viewModel.setRange(range) }, - label = { - Text(when (range) { - TrendsRange.DAYS_30 -> "30d" - TrendsRange.MONTHS_3 -> "3m" - TrendsRange.MONTHS_6 -> "6m" - TrendsRange.ALL -> "All" - }, style = MaterialTheme.typography.labelSmall) - } - ) + val selected = state.range == range + val label = when (range) { + TrendsRange.DAYS_30 -> "30D" + TrendsRange.MONTHS_3 -> "3M" + TrendsRange.MONTHS_6 -> "6M" + TrendsRange.ALL -> "ALL" + } + Box( + modifier = Modifier + .background( + if (selected) SurfaceHiColor else SurfaceLoColor, + RoundedCornerShape(999.dp) + ) + .border( + 1.dp, + if (selected) BorderColor else BorderSoftColor, + RoundedCornerShape(999.dp) + ) + .clickable { viewModel.setRange(range) } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = label, + fontFamily = JetBrainsMono, + fontSize = 11.5.sp, + color = if (selected) FgColor else FgSubtleColor, + letterSpacing = 0.2.sp + ) + } } } } - // Summary header if (state.conditionFrequencies.isEmpty()) { item { Box( - Modifier.fillMaxWidth().padding(vertical = 48.dp), + Modifier + .fillMaxWidth() + .padding(vertical = 48.dp, horizontal = 18.dp), contentAlignment = Alignment.Center ) { Text( "No conditions logged in this range.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 16.sp, + color = FgMutedColor ) } } } else { item { - Text( - "Logged this period", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) + SectionLabel(title = "Most logged", num = "I.") } - items(state.conditionFrequencies) { freq -> - ConditionFrequencyCard( - frequency = freq, - isSelected = state.selectedCondition?.conditionKey == freq.definition.conditionKey, + items( + items = state.conditionFrequencies, + key = { it.definition.conditionKey } + ) { freq -> + val isFirst = state.conditionFrequencies.first() == freq + val isSelected = state.selectedCondition?.conditionKey == freq.definition.conditionKey + + TrendsConditionCard( + freq = freq, + accent = isFirst, + isSelected = isSelected, onClick = { viewModel.selectCondition( - if (state.selectedCondition?.conditionKey == freq.definition.conditionKey) null - else freq.definition + if (isSelected) null else freq.definition ) } ) @@ -112,61 +140,127 @@ fun HealthTrendsScreen( } @Composable -private fun ConditionFrequencyCard( - frequency: ConditionFrequency, +private fun TrendsConditionCard( + freq: ConditionFrequency, + accent: Boolean, isSelected: Boolean, onClick: () -> Unit ) { - val barColor = MaterialTheme.colorScheme.primary - - ElevatedCard( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 4.dp) + .background(SurfaceColor, RoundedCornerShape(14.dp)) + .border( + 1.dp, + if (accent) AccentFaintColor else BorderSoftColor, + RoundedCornerShape(14.dp) + ) + .clickable(onClick = onClick) ) { - Column(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.padding(14.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { + // Name + recurring badge Row( - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(frequency.definition.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) - if (frequency.isRecurring) { - Badge(containerColor = MaterialTheme.colorScheme.tertiaryContainer) { - Text("Recurring", style = MaterialTheme.typography.labelSmall) - } + Text( + text = freq.definition.displayName, + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontSize = 16.sp, + color = FgColor + ) + if (freq.isRecurring) { + Text( + text = "· RECURRING", + fontFamily = JetBrainsMono, + fontSize = 8.sp, + color = AccentColor, + letterSpacing = 1.6.sp, + modifier = Modifier.padding(bottom = 2.dp) + ) } } + Spacer(Modifier.height(2.dp)) Text( - "${frequency.count} times · avg rating ${"%.1f".format(frequency.avgRating)}/5", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "${freq.count}× · AVG ${"%.1f".format(freq.avgRating)}/5", + fontFamily = JetBrainsMono, + fontSize = 10.sp, + color = FgSubtleColor, + letterSpacing = 0.6.sp ) } + + // Sparkline bars + if (freq.weeklyData.isNotEmpty()) { + val maxW = (freq.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f) + Row( + modifier = Modifier.height(26.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + freq.weeklyData.forEach { w -> + val barFraction = (w / maxW).coerceAtLeast(0.08f) + Box( + modifier = Modifier + .width(6.dp) + .fillMaxHeight(barFraction) + .background( + if (accent) AccentColor else SurfaceHiColor, + RoundedCornerShape(1.dp) + ) + .border(0.5.dp, BorderSoftColor, RoundedCornerShape(1.dp)) + ) + } + } + Spacer(Modifier.width(6.dp)) + } + + // Big count number Text( - "×${frequency.count}", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = freq.count.toString(), + fontFamily = CormorantGaramond, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + color = if (accent) AccentColor else FgMutedColor ) } - if (isSelected && frequency.weeklyData.isNotEmpty()) { + // Expanded weekly chart + if (isSelected && freq.weeklyData.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - Text("Weekly occurrences", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) - val maxCount = (frequency.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f) - Canvas(modifier = Modifier.fillMaxWidth().height(60.dp)) { - val barW = size.width / (frequency.weeklyData.size * 1.5f) - val gap = barW * 0.5f - frequency.weeklyData.forEachIndexed { i, count -> - val h = (count / maxCount) * size.height * 0.9f - drawRect( - color = barColor, - topLeft = Offset(i * (barW + gap), size.height - h), - size = Size(barW, h) + HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp) + Spacer(Modifier.height(12.dp)) + Text( + "WEEKLY OCCURRENCES", + fontFamily = JetBrainsMono, + fontSize = 9.sp, + color = FgSubtleColor, + letterSpacing = 0.5.sp + ) + Spacer(Modifier.height(8.dp)) + val maxCount = (freq.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f) + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + freq.weeklyData.forEachIndexed { i, count -> + val fraction = (count / maxCount).coerceAtLeast(0.04f) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(fraction) + .background(AccentColor, RoundedCornerShape(4.dp)) ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 775fdce..0128f6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }