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