Compare commits

...

2 Commits

Author SHA1 Message Date
27403525a8 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>
2026-05-23 03:41:32 -05:00
2105cf861c Fix intimacy add-encounter form and Health Trends loading
Intimacy form: move AddIntimacyForm from an inline LazyColumn card (where
Cancel/Add buttons land below the fold and keyboard) to a ModalBottomSheet
that is always fully visible and slides above the keyboard. Also fix a
race-condition in addIntimacyLog where `_uiState.value = state.copy(isDirty=true)`
used a stale snapshot and could overwrite a concurrent Room Flow update that had
already refreshed intimacyLogs — replaced with the atomic `_uiState.update {}`.

Health Trends: replace `onEach {...}.launchIn()` with `collectLatest {}` inside
a try/catch so any exception during setup or DB query resolves isLoading rather
than leaving the screen stuck on an infinite spinner. collectLatest also cancels
in-flight computations when the user switches the time range, preventing stale
results. Removed unused CycleRepository / CyclePredictionEngine imports.
Update deprecated TrendsRange.values() → TrendsRange.entries in the screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:44:57 -05:00
17 changed files with 2720 additions and 1141 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,8 +83,7 @@ class CyclePredictionEngine @Inject constructor() {
today: LocalDate,
rangeStart: LocalDate,
rangeEnd: LocalDate
): CyclePrediction {
return CyclePrediction(
): CyclePrediction = CyclePrediction(
currentCycleStartDate = null,
currentCycleDay = 0,
currentPhase = CyclePhase.NO_DATA,
@@ -90,70 +97,139 @@ class CyclePredictionEngine @Inject constructor() {
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
)
}
}
},
Column(
modifier = Modifier
.fillMaxSize()
.background(BgColor)
) {
// ── Top bar ─────────────────────────────────────────────────────────
DiaryTopBar(
subtitle = activeProfile?.name,
actions = {
IconButton(onClick = onTrendsClick) {
Icon(Icons.AutoMirrored.Filled.TrendingUp, contentDescription = "Trends")
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) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
IconButton(
onClick = onSettingsClick,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Settings,
contentDescription = "Settings",
tint = FgMutedColor,
modifier = Modifier.size(18.dp)
)
}
if (activeProfile != null) {
val avatarColor = parseColor(activeProfile.avatarColor)
IconButton(onClick = { viewModel.showProfileSheet() }) {
AvatarDot(name = activeProfile.name, avatarColor = avatarColor, size = 32)
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
)
}
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Context banner (female only)
if (activeProfile?.profileType == ProfileType.FEMALE.name) {
val prediction = state.prediction
ContextBanner(
prediction = prediction,
// ── Cycle status banner (female profiles only) ───────────────────────
if (isFemale) {
CycleStatusBanner(
prediction = state.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 ->
// ── 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 = h,
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,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgFaintColor,
letterSpacing = 1.sp
)
}
}
// Calendar grid
val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name
CalendarGrid(
days = state.dayStates,
// ── 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,
onDayClick = { onDayClick(it.toString()) }
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,
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
) {
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")
}
}
}
@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(
day: DayState,
isFemale: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val phaseColor = if (isFemale) phaseColor(day.phase) else null
val textAlpha = if (day.isCurrentMonth) 1f else 0.35f
Box(
modifier = modifier
.aspectRatio(1f)
.padding(1.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
// 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
)
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
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)
}
// Today ring
if (day.isToday) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.TopCenter)
.offset(y = 3.dp)
)
// ─── Day cell ────────────────────────────────────────────────────────────────
@Composable
private fun AubergineDay(
day: DayState,
isFemale: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
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)
// 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.fillMaxSize().padding(2.dp),
modifier = modifier
.clickable(onClick = onClick)
.alpha(if (day.isCurrentMonth) 1f else 0.35f)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Day number
// 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
) {
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()) {
// Icon row (up to 2 condition icons + intimacy heart)
if (day.isCurrentMonth && (icons.isNotEmpty() || day.hasIntimacy)) {
Spacer(Modifier.height(2.dp))
Row(
modifier = Modifier.padding(top = 1.dp),
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalAlignment = Alignment.CenterVertically
) {
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)
icons.take(2).forEach { icon ->
Text(icon, fontSize = 8.5.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)
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)
AvatarDot(
name = profile.name,
avatarColor = profile.avatarColor,
size = 36
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
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") }
}
if (profile.isActive) {
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 = {
TextButton(
onClick = {
onProfileSelected(target.id)
confirmTarget = null
onDismiss()
}) { Text("Switch") }
},
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,24 +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
@@ -31,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) {
@@ -41,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 {
@@ -54,39 +70,63 @@ fun DayDetailScreen(
}
Scaffold(
containerColor = BgColor,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(dateLabel, style = MaterialTheme.typography.titleMedium) },
navigationIcon = {
IconButton(onClick = {
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(BgColor)
) {
DiaryTopBar(
showBack = true,
onBack = {
if (state.isDirty) viewModel.saveAndExit()
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
},
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),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 32.dp)
) {
// Cycle section (female only)
// I. Cycle (female only)
if (state.isFemale) {
item {
SectionLabel(title = "Cycle", num = "I.")
CycleSection(
state = state,
onTogglePeriodStart = viewModel::togglePeriodStart,
onTogglePeriodEnd = viewModel::togglePeriodEnd
)
}
item { HorizontalDivider() }
}
// Conditions section
// II. Symptoms
item {
SectionLabel(title = "Symptoms", num = if (state.isFemale) "II." else "I.")
ConditionsSection(
definitions = state.definitions,
selectedConditions = state.conditions,
@@ -95,18 +135,20 @@ fun DayDetailScreen(
)
}
// Notes
// III. Notes
item {
SectionLabel(title = "Notes", num = if (state.isFemale) "III." else "II.")
NotesSection(
notes = state.notes,
onNotesChange = viewModel::updateNotes
)
}
item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) }
item { OrnamentDivider() }
// Intimacy section
// IV. Intimacy
item {
SectionLabel(title = "Intimacy", num = if (state.isFemale) "IV." else "III.")
IntimacySection(
logs = state.intimacyLogs,
profiles = state.allProfiles,
@@ -115,14 +157,48 @@ fun DayDetailScreen(
state.currentPhase == CyclePhase.FERTILE_WINDOW ||
state.currentPhase == CyclePhase.OVULATION
),
onAdd = { pType, pName, time, protected ->
viewModel.addIntimacyLog(pType, pName, time, protected)
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, 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
}
@Composable
@@ -131,85 +207,131 @@ private fun CycleSection(
onTogglePeriodStart: () -> Unit,
onTogglePeriodEnd: () -> Unit
) {
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"
val cycleState = cycleDayUiState(state)
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-of-period badge
if (state.hasActiveCycle && state.periodDayNumber > 0) {
Text(
"Period day ${state.periodDayNumber}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
}
// Period action chips
val showStarted = cycleState == CycleDayUiState.NO_DATA ||
cycleState == CycleDayUiState.START_CONFIRMED ||
cycleState == CycleDayUiState.PREDICTED
val showEnded = cycleState == CycleDayUiState.MID_OPEN ||
cycleState == CycleDayUiState.END_CONFIRMED
Text("Period", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip(
selected = state.isPeriodStart,
if (showStarted || showEnded) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (showStarted) {
AubergineFilterChip(
selected = cycleState == CycleDayUiState.START_CONFIRMED,
onClick = onTogglePeriodStart,
label = { Text("Period started") },
leadingIcon = {
Icon(
if (state.isPeriodStart || state.hasActiveCycle) Icons.Default.Check else Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(16.dp)
label = "Period started",
leadingIcon = if (cycleState == CycleDayUiState.START_CONFIRMED) "" else ""
)
}
)
FilterChip(
selected = state.isPeriodEnd,
if (showEnded) {
AubergineFilterChip(
selected = cycleState == CycleDayUiState.END_CONFIRMED,
onClick = onTogglePeriodEnd,
enabled = state.isPeriodEnd ||
(state.hasActiveCycle && state.cycleStartDate != null && state.date > state.cycleStartDate),
label = { Text("Period ended") },
leadingIcon = {
Icon(
if (state.isPeriodEnd) 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
if (state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED && !state.isPeriodStart) {
// 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)
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
) {
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)
}
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(
@@ -218,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 = category.replace("_", " ").lowercase()
.replaceFirstChar { it.uppercase() }
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()
@@ -242,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
)
}
}
@@ -326,21 +477,62 @@ 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(
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) },
label = { Text("Notes") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4,
supportingText = { Text("${notes.length}/256") }
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(
logs: List<IntimacyLogEntity>,
@@ -350,58 +542,54 @@ 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
)
}
if (isExpanded) {
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))
}
DiaryTextLink(
onClick = { showAddForm = true },
leading = "+"
) {
Text(
"Add encounter",
fontFamily = InstrumentSans,
fontSize = 13.sp,
color = AccentColor
)
}
}
if (showAddForm) {
ModalBottomSheet(
onDismissRequest = { showAddForm = false },
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,
activeProfileId = activeProfileId,
onConfirm = { pType, pName, time, protected ->
onAdd(pType, pName, time, protected)
onConfirm = { pType, pName, time, isProtected ->
onAdd(pType, pName, time, isProtected)
showAddForm = false
},
onCancel = { showAddForm = false }
)
} else {
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")
}
}
}
}
}
@@ -412,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))
}
}
if (isFemaleInFertileWindow && !log.protected) {
Text("", fontSize = 14.sp, color = AccentColor)
Column(modifier = Modifier.weight(1f)) {
Text(
"🌿 Unprotected · Fertile window",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)
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)
)
}
}
}
}
@@ -446,72 +659,129 @@ private fun AddIntimacyForm(
var participantType by remember { mutableStateOf("OTHER") }
var participantName by remember { mutableStateOf("") }
var timeOfDay by remember { mutableStateOf("") }
var protected by remember { mutableStateOf(true) }
var isProtected by remember { mutableStateOf(true) }
val otherProfile = profiles.firstOrNull { it.id != activeProfileId }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("New encounter", style = MaterialTheme.typography.titleSmall)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"New encounter",
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
color = FgColor
)
// Participant selector
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("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, HH:MM)") },
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
) {
Text("Protected", style = MaterialTheme.typography.bodyMedium)
Switch(checked = protected, onCheckedChange = { protected = it })
Column {
Text("Protected", fontFamily = InstrumentSans, fontSize = 14.sp, color = FgColor)
Text(
if (isProtected) "Contraception used" else "No contraception",
fontFamily = InstrumentSans,
fontSize = 12.sp,
color = FgSubtleColor
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") }
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),
colors = ButtonDefaults.outlinedButtonColors(contentColor = FgColor),
border = androidx.compose.foundation.BorderStroke(1.dp, BorderColor)
) { Text("Cancel", fontFamily = InstrumentSans) }
Button(
onClick = {
onConfirm(
participantType,
participantName.ifBlank { null },
timeOfDay.ifBlank { null },
protected
isProtected
)
},
modifier = Modifier.weight(1f)
) { Text("Add") }
}
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

@@ -33,6 +33,7 @@ data class DayDetailUiState(
val periodDayNumber: Int = 0, // day-of-period for this date (1 = first day)
val hasActiveCycle: Boolean = false, // a cycle record spans this date (start logged, regardless of which day)
val cycleStartDate: LocalDate? = null,
val cycleEndDate: LocalDate? = null, // confirmed end of the spanning cycle, null = still open
val notes: String = "",
val conditions: Map<String, Int> = emptyMap(),
val definitions: List<ConditionDefinitionEntity> = emptyList(),
@@ -93,6 +94,7 @@ class DayDetailViewModel @Inject constructor(
val cycleRecord = if (isFemale) cycleRepository.getRecordContainingDate(activeProfile.id, dateStr) else null
val hasActiveCycle = cycleRecord != null
val cycleStartDate = cycleRecord?.let { LocalDate.parse(it.cycleStart) }
val cycleEndDate = cycleRecord?.cycleEnd?.let { LocalDate.parse(it) }
val periodDayNumber = if (cycleRecord != null) {
(date.toEpochDay() - LocalDate.parse(cycleRecord.cycleStart).toEpochDay()).toInt() + 1
} else 0
@@ -108,6 +110,7 @@ class DayDetailViewModel @Inject constructor(
periodDayNumber = periodDayNumber,
hasActiveCycle = hasActiveCycle,
cycleStartDate = cycleStartDate,
cycleEndDate = cycleEndDate,
notes = dayLog.notes ?: "",
conditions = conditions,
definitions = defsToShow,
@@ -121,6 +124,42 @@ class DayDetailViewModel @Inject constructor(
intimacyRepository.getLogsForDay(dateStr, activeProfile.id)
.onEach { logs -> _uiState.value = _uiState.value.copy(intimacyLogs = logs) }
.launchIn(viewModelScope)
if (isFemale) {
cycleRepository.getCycleRecords(activeProfile.id)
.onEach { records ->
val updatedIsPeriodStart = records.any { it.cycleStart == dateStr }
val updatedIsPeriodEnd = records.any { it.cycleEnd == dateStr }
val updatedRecord = records.firstOrNull { r ->
r.cycleStart <= dateStr && (r.cycleEnd == null || r.cycleEnd >= dateStr)
}
val updatedHasActive = updatedRecord != null
val updatedCycleStartDate = updatedRecord?.let { LocalDate.parse(it.cycleStart) }
val updatedCycleEndDate = updatedRecord?.cycleEnd?.let { LocalDate.parse(it) }
val updatedPeriodDayNum = updatedRecord?.let {
(date.toEpochDay() - LocalDate.parse(it.cycleStart).toEpochDay()).toInt() + 1
} ?: 0
val updatedPrediction = predictionEngine.buildPrediction(
records, activeProfile.cycleLengthDefault, LocalDate.now()
)
val updatedPhase = updatedPrediction.phaseMap[date] ?: CyclePhase.NO_DATA
val updatedCycleDay = if (updatedPrediction.currentCycleStartDate != null) {
maxOf(1, (date.toEpochDay() - updatedPrediction.currentCycleStartDate.toEpochDay()).toInt() + 1)
} else 0
_uiState.value = _uiState.value.copy(
isPeriodStart = updatedIsPeriodStart,
isPeriodEnd = updatedIsPeriodEnd,
hasActiveCycle = updatedHasActive,
cycleStartDate = updatedCycleStartDate,
cycleEndDate = updatedCycleEndDate,
periodDayNumber = updatedPeriodDayNum,
prediction = updatedPrediction,
currentPhase = updatedPhase,
cycleDay = updatedCycleDay
)
}
.launchIn(viewModelScope)
}
}
fun togglePeriodStart() {
@@ -141,6 +180,7 @@ class DayDetailViewModel @Inject constructor(
periodDayNumber = 0,
hasActiveCycle = false,
cycleStartDate = null,
cycleEndDate = null,
isDirty = true
)
} else {
@@ -157,6 +197,7 @@ class DayDetailViewModel @Inject constructor(
periodDayNumber = 1,
hasActiveCycle = true,
cycleStartDate = date,
cycleEndDate = null,
isDirty = true
)
}
@@ -170,24 +211,22 @@ class DayDetailViewModel @Inject constructor(
val dayLog = state.dayLog ?: return@launch
if (state.isPeriodEnd) {
// Remove end date from the cycle record
cycleRepository.removePeriodEnd(profileId, dateStr)
_uiState.value = state.copy(isPeriodEnd = false, isDirty = true)
_uiState.value = state.copy(isPeriodEnd = false, cycleEndDate = null, isDirty = true)
} else {
// Mark as end: close the current open cycle record at this date
cycleRepository.endCurrentCycle(profileId, dateStr)
// Also mark this day as a period day if not already
if (!state.periodActive) {
val updatedLog = dayLog.copy(periodActive = true)
dayLogRepository.upsertDayLog(updatedLog)
_uiState.value = state.copy(
isPeriodEnd = true,
cycleEndDate = date,
periodActive = true,
dayLog = updatedLog,
isDirty = true
)
} else {
_uiState.value = state.copy(isPeriodEnd = true, isDirty = true)
_uiState.value = state.copy(isPeriodEnd = true, cycleEndDate = date, isDirty = true)
}
}
}
@@ -232,10 +271,9 @@ class DayDetailViewModel @Inject constructor(
}
}
fun addIntimacyLog(participantType: String, participantName: String?, timeOfDay: String?, protected: Boolean) {
fun addIntimacyLog(participantType: String, participantName: String?, timeOfDay: String?, isProtected: Boolean) {
viewModelScope.launch {
val state = _uiState.value
val profileId = state.activeProfile?.id ?: return@launch
val profileId = _uiState.value.activeProfile?.id ?: return@launch
intimacyRepository.insertLog(
IntimacyLogEntity(
date = dateStr,
@@ -243,11 +281,13 @@ class DayDetailViewModel @Inject constructor(
participantType = participantType,
participantName = participantName,
timeOfDay = timeOfDay,
protected = protected,
protected = isProtected,
shared = participantType != "OTHER"
)
)
_uiState.value = state.copy(isDirty = true)
// Use update{} so we never overwrite a concurrent Flow emission that already
// refreshed intimacyLogs; only the isDirty flag changes here.
_uiState.update { it.copy(isDirty = true) }
}
}

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")
}
}
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
)
}
) { padding ->
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 -> ""
}
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)
}
}
}
// 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)
)
// 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(
text = phaseName,
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 30.sp,
lineHeight = 32.sp,
color = FgColor
)
// 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))
// Subtitle
val phaseSubtitle = when (prediction.currentPhase) {
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED ->
prediction.nextPeriodDate?.let {
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)
"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
)
}
}
// ── 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))
}
// ── 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
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 })
}
}
}
}
}
}
@Composable
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
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(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
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 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
) {
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))
Row(modifier = Modifier.fillMaxWidth()) {
lengths.forEachIndexed { i, length ->
Text(
"Last ${cycles.size} cycles · Average: ${avg.toInt()} days",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
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,6 +32,11 @@ fun OnboardingScreen(
) {
val state by viewModel.uiState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(BgColor)
) {
when (state.step) {
0 -> WelcomeStep(onNext = { viewModel.nextStep() })
1 -> ProfileSetupStep(
@@ -54,36 +65,175 @@ fun OnboardingScreen(
isLoading = state.isLoading
)
}
}
}
// ─── Welcome step ─────────────────────────────────────────────────────────────
@Composable
private fun WelcomeStep(onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🩺", style = MaterialTheme.typography.displayLarge)
// "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 = "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,
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,
color = MaterialTheme.colorScheme.onSurfaceVariant
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
)
Spacer(Modifier.height(48.dp))
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) {
Text("Get Started")
}
}
}
// ─── 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()
.padding(padding)
.background(BgColor)
) {
DiaryTopBar(showBack = true, onBack = onBack, title = "Settings")
Column(
modifier = Modifier
.fillMaxSize()
.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") }
// ── 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()
// 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) }
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
)
}
}
}
)
}
// Profile settings
// ── II. Profiles ──────────────────────────────────────────────────
SectionLabel(title = "Profiles", num = "II.")
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
state.profiles.forEach { profile ->
SettingsSection("Profile: ${profile.name}") {
ProfileSettingsCard(
ProfileRow(
profile = profile,
onNameChange = { viewModel.updateProfileName(profile.id, it) },
onColorChange = { viewModel.updateProfileColor(profile.id, it) }
)
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 }
onColorChange = { viewModel.updateProfileColor(profile.id, it) },
onClearData = { clearProfileTarget = profile.id }
)
Spacer(Modifier.height(8.dp))
}
}
// 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 }
// ── 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))
// ── Colophon ──────────────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
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)
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,97 +132,186 @@ 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
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
)
}
Text(
text = if (expanded) "" else "",
fontFamily = JetBrainsMono,
fontSize = 14.sp,
color = FgFaintColor
)
}
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") },
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") }
}) {
Text("Save", fontFamily = InstrumentSans, color = AccentColor)
}
}
)
} else {
ListItem(
headlineContent = { Text(profile.name) },
supportingContent = { Text("Tap to edit name") },
modifier = Modifier.clickable { nameEditMode = true }
)
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))
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 ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
colorHexes.forEach { hex ->
val color = parseColor(hex)
val isSelected = profile.avatarColor == hex
Box(
@@ -233,10 +319,114 @@ private fun ProfileSettingsCard(
.size(32.dp)
.clip(CircleShape)
.background(color)
.then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
.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
.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 = colorScheme,
colorScheme = AubergineNocturne,
typography = Typography,
content = content
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")
}
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"
}
)
}
) { padding ->
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.values().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)
TrendsRange.entries.forEach { range ->
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(
"${frequency.count} times · avg rating ${"%.1f".format(frequency.avgRating)}/5",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
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}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
text = "${freq.count}× · AVG ${"%.1f".format(freq.avgRating)}/5",
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgSubtleColor,
letterSpacing = 0.6.sp
)
}
if (isSelected && frequency.weeklyData.isNotEmpty()) {
// 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(
text = freq.count.toString(),
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
color = if (accent) AccentColor else FgMutedColor
)
}
// 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))
)
}
}

View File

@@ -8,10 +8,8 @@ import com.hsdiary.data.db.entity.DayLogEntity
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.DayLogRepository
import com.hsdiary.data.repository.ProfileRepository
import com.hsdiary.domain.CyclePredictionEngine
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -50,19 +48,33 @@ class HealthTrendsViewModel @Inject constructor(
val uiState: StateFlow<HealthTrendsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch { load() }
viewModelScope.launch {
try {
load()
} catch (e: Exception) {
// Surface the error state rather than leaving the screen on an infinite spinner
_uiState.value = HealthTrendsUiState(isLoading = false)
}
}
}
private suspend fun load() {
val activeId = userPreferences.activeProfileId.first()
val profiles = profileRepository.getAllProfilesOnce()
val profile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() ?: return
val profile = profiles.find { it.id == activeId } ?: profiles.firstOrNull()
if (profile == null) {
_uiState.value = HealthTrendsUiState(isLoading = false)
return
}
val isFemale = profile.profileType == ProfileType.FEMALE.name
val allDefs = dayLogRepository.getAllDefinitions()
.filter { it.profileVisibility == "ALL" || (isFemale && it.profileVisibility == "FEMALE_ONLY") }
// collectLatest cancels any in-flight computation whenever the range or selection
// changes, so the UI never shows stale results when the user rapidly switches tabs.
_range.combine(_selectedCondition) { range, sel -> range to sel }
.onEach { (range, sel) ->
.collectLatest { (range, sel) ->
val today = LocalDate.now()
val startDate = when (range) {
TrendsRange.DAYS_30 -> today.minusDays(29)
@@ -101,7 +113,6 @@ class HealthTrendsViewModel @Inject constructor(
isFemale = isFemale
)
}
.launchIn(viewModelScope)
}
private fun buildWeeklyData(

View File

@@ -24,6 +24,7 @@ compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }