Implement major visual overhaul and brand identity transition to "Aubergine Nocturne" style across all screens.

### Design & Theming
- Implement a fixed dark editorial theme with a palette of deep aubergine, warm copper (Accent), and cream (Text) using OKLCH-to-sRGB color tokens.
- Integrate premium typography using Google Fonts:
    - **Cormorant Garamond:** Editorial display serif for headlines and status labels.
    - **Instrument Sans:** Clean UI sans for body text and actions.
    - **JetBrains Mono:** Monospaced typeface for dates, counters, and ledger-style labels.
- Replace standard Material3 components with custom "Aubergine" styled components (cards, chips, and dividers).

### Calendar & Insights
- Redesign the Calendar grid with refined "bubble" day cells, high-contrast period indicators, and a horizontal "Today" footer.
- Add a new `PhaseArc` visualization in Cycle Insights to show the current phase progress within the overall cycle length.
- Enhance `CycleHistoryChart` with dashed average lines and active-state highlighting for the latest cycle.
- Refine the cycle status banner with a pulsing `GlowDot` and detailed phase descriptions.

### Day Detail & Health Trends
- Reorganize Day Detail into numbered sections (Cycle, Symptoms, Notes, Intimacy).
- Redesign the symptom logging interface with expanded category drawers and custom rating boxes.
- Transform Health Trends with a sparkline-based condition frequency view and a mono-styled range selector.
- Update `AddIntimacyForm` to follow the new typography and color scheme within its modal bottom sheet.

### Onboarding & Settings
- Rewrite the Onboarding flow to feature an editorial "Welcome" step with a monogram header and private-ledger tagline.
- Overhaul Settings with custom toggle tabs for preferences and a redesigned profile management card.

### Domain & Logic
- Update `CyclePredictionEngine` to include logic for `avgPeriodLength` and a more precise phase mapping algorithm that eliminates gaps between cycle windows.
- Refine `CycleRepository` to set `cycleLength` only when a new cycle starts, rather than at period end, to ensure accurate start-to-next-start calculations.
- Add `ui-text-google-fonts` dependency to support the new typeface requirements.

Signed-off-by: whitlocktech <whitlocktech@gmail.com>
This commit is contained in:
2026-05-23 03:41:32 -05:00
parent 2105cf861c
commit 27403525a8
15 changed files with 2562 additions and 1091 deletions

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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<CycleRecordEntity>,
latestStart: LocalDate,
avgLength: Int,
ovulation: LocalDate,
fertileStart: LocalDate,
fertileEnd: LocalDate,
avgCycleLength: Int,
avgPeriodLength: Int,
rangeStart: LocalDate,
rangeEnd: LocalDate
): Map<LocalDate, CyclePhase> {
val map = mutableMapOf<LocalDate, CyclePhase>()
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<LocalDate, CyclePhase>,
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<LocalDate, CyclePhase>,
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<LocalDate, CyclePhase>,
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
}
}

View File

@@ -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<DayState>,
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<String, String> = 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<String> {
val icons = mutableListOf<String>()
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
}

View File

@@ -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
)
}
}

View File

@@ -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<ProfileSwitchItem?>(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)
}
}
)
}

View File

@@ -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<String>()) }
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)
}

View File

@@ -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<Int>) {
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<Int>) {
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
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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<Long?>(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 <T> AubergineTabs(
options: List<Pair<String, T>>,
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
)
}
}

View File

@@ -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

View File

@@ -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
)
}

View File

@@ -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() }

View File

@@ -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))
)
}
}