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.ui.tooling.preview)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.material.icons.extended)
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
implementation(libs.compose.ui.text.google.fonts)
|
||||||
implementation(libs.lifecycle.runtime.ktx)
|
implementation(libs.lifecycle.runtime.ktx)
|
||||||
implementation(libs.lifecycle.viewmodel.compose)
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.lifecycle.runtime.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) {
|
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 current = dao.getCurrentCycleRecord(profileId) ?: return
|
||||||
val length = java.time.LocalDate.parse(endDate)
|
dao.updateCycleRecord(current.copy(cycleEnd = endDate))
|
||||||
.toEpochDay()
|
|
||||||
.minus(java.time.LocalDate.parse(current.cycleStart).toEpochDay())
|
|
||||||
.toInt() + 1
|
|
||||||
dao.updateCycleRecord(current.copy(cycleEnd = endDate, cycleLength = length))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removePeriodStart(profileId: Long, date: String) {
|
suspend fun removePeriodStart(profileId: Long, date: String) {
|
||||||
|
|||||||
@@ -21,8 +21,19 @@ class CyclePredictionEngine @Inject constructor() {
|
|||||||
if (records.isEmpty()) return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd)
|
if (records.isEmpty()) return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd)
|
||||||
|
|
||||||
val completedLengths = records.mapNotNull { it.cycleLength }
|
val completedLengths = records.mapNotNull { it.cycleLength }
|
||||||
val avgLength = if (completedLengths.isEmpty()) defaultCycleLength
|
val avgCycleLength = if (completedLengths.isEmpty()) maxOf(14, defaultCycleLength)
|
||||||
else completedLengths.takeLast(12).average().roundToInt()
|
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 {
|
val tier = when {
|
||||||
completedLengths.size >= 12 -> 4
|
completedLengths.size >= 12 -> 4
|
||||||
@@ -31,23 +42,20 @@ class CyclePredictionEngine @Inject constructor() {
|
|||||||
else -> 1
|
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)
|
?: return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd)
|
||||||
|
|
||||||
val cycleDay = (today.toEpochDay() - latestStart.toEpochDay()).toInt() + 1
|
val cycleDay = (today.toEpochDay() - latestStart.toEpochDay()).toInt() + 1
|
||||||
val nextPeriod = latestStart.plusDays(avgLength.toLong())
|
val nextPeriod = latestStart.plusDays(avgCycleLength.toLong())
|
||||||
val lutealLength = 14
|
val ovulation = latestStart.plusDays((avgCycleLength - LUTEAL_PHASE_LENGTH).toLong())
|
||||||
val ovulation = latestStart.plusDays((avgLength - lutealLength).toLong())
|
val fertileStart = ovulation.minusDays(FERTILE_WINDOW_LEAD.toLong())
|
||||||
val fertileStart = ovulation.minusDays(5)
|
val fertileEnd = ovulation.minusDays(1)
|
||||||
val fertileEnd = ovulation.plusDays(1)
|
|
||||||
|
|
||||||
val phaseMap = buildPhaseMap(
|
val phaseMap = buildPhaseMap(
|
||||||
records = records,
|
records = records,
|
||||||
latestStart = latestStart,
|
avgCycleLength = avgCycleLength,
|
||||||
avgLength = avgLength,
|
avgPeriodLength = avgPeriodLength,
|
||||||
ovulation = ovulation,
|
|
||||||
fertileStart = fertileStart,
|
|
||||||
fertileEnd = fertileEnd,
|
|
||||||
rangeStart = rangeStart,
|
rangeStart = rangeStart,
|
||||||
rangeEnd = rangeEnd
|
rangeEnd = rangeEnd
|
||||||
)
|
)
|
||||||
@@ -62,7 +70,7 @@ class CyclePredictionEngine @Inject constructor() {
|
|||||||
fertileWindowStart = fertileStart,
|
fertileWindowStart = fertileStart,
|
||||||
fertileWindowEnd = fertileEnd,
|
fertileWindowEnd = fertileEnd,
|
||||||
ovulationDate = ovulation,
|
ovulationDate = ovulation,
|
||||||
averageCycleLength = avgLength,
|
averageCycleLength = avgCycleLength,
|
||||||
cyclesLogged = records.size,
|
cyclesLogged = records.size,
|
||||||
tier = tier,
|
tier = tier,
|
||||||
daysUntilNextPeriod = (nextPeriod.toEpochDay() - today.toEpochDay()).toInt().takeIf { it >= 0 },
|
daysUntilNextPeriod = (nextPeriod.toEpochDay() - today.toEpochDay()).toInt().takeIf { it >= 0 },
|
||||||
@@ -75,8 +83,7 @@ class CyclePredictionEngine @Inject constructor() {
|
|||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
rangeStart: LocalDate,
|
rangeStart: LocalDate,
|
||||||
rangeEnd: LocalDate
|
rangeEnd: LocalDate
|
||||||
): CyclePrediction {
|
): CyclePrediction = CyclePrediction(
|
||||||
return CyclePrediction(
|
|
||||||
currentCycleStartDate = null,
|
currentCycleStartDate = null,
|
||||||
currentCycleDay = 0,
|
currentCycleDay = 0,
|
||||||
currentPhase = CyclePhase.NO_DATA,
|
currentPhase = CyclePhase.NO_DATA,
|
||||||
@@ -90,70 +97,139 @@ class CyclePredictionEngine @Inject constructor() {
|
|||||||
daysUntilNextPeriod = null,
|
daysUntilNextPeriod = null,
|
||||||
phaseMap = emptyMap()
|
phaseMap = emptyMap()
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPhaseMap(
|
private fun buildPhaseMap(
|
||||||
records: List<CycleRecordEntity>,
|
records: List<CycleRecordEntity>,
|
||||||
latestStart: LocalDate,
|
avgCycleLength: Int,
|
||||||
avgLength: Int,
|
avgPeriodLength: Int,
|
||||||
ovulation: LocalDate,
|
|
||||||
fertileStart: LocalDate,
|
|
||||||
fertileEnd: LocalDate,
|
|
||||||
rangeStart: LocalDate,
|
rangeStart: LocalDate,
|
||||||
rangeEnd: LocalDate
|
rangeEnd: LocalDate
|
||||||
): Map<LocalDate, CyclePhase> {
|
): Map<LocalDate, CyclePhase> {
|
||||||
val map = mutableMapOf<LocalDate, CyclePhase>()
|
val map = mutableMapOf<LocalDate, CyclePhase>()
|
||||||
|
if (records.isEmpty()) return map
|
||||||
|
|
||||||
// Mark confirmed period days from actual records
|
val sortedRecords = records.sortedBy { it.cycleStart }
|
||||||
records.forEach { record ->
|
|
||||||
val start = LocalDate.parse(record.cycleStart)
|
// Each actual cycle record (its cycle window ends when the next record begins,
|
||||||
val end = record.cycleEnd?.let { LocalDate.parse(it) } ?: start.plusDays(4)
|
// or projects forward by avgCycleLength if it's the last one)
|
||||||
var d = start
|
sortedRecords.forEachIndexed { index, record ->
|
||||||
while (!d.isAfter(end) && !d.isAfter(rangeEnd)) {
|
val cycleStart = LocalDate.parse(record.cycleStart)
|
||||||
if (!d.isBefore(rangeStart)) map[d] = CyclePhase.MENSTRUATION_CONFIRMED
|
val confirmedEnd = record.cycleEnd?.let { LocalDate.parse(it) }
|
||||||
d = d.plusDays(1)
|
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
|
// Project predicted cycles forward until we cover rangeEnd
|
||||||
var cycleStart = latestStart
|
var projectedStart = LocalDate.parse(sortedRecords.last().cycleStart)
|
||||||
while (cycleStart.isBefore(rangeEnd)) {
|
.plusDays(avgCycleLength.toLong())
|
||||||
val cycleOvulation = cycleStart.plusDays((avgLength - 14).toLong())
|
while (projectedStart.isBefore(rangeEnd)) {
|
||||||
val cycleFertileStart = cycleOvulation.minusDays(5)
|
val periodEnd = projectedStart.plusDays((avgPeriodLength - 1).toLong())
|
||||||
val cycleFertileEnd = cycleOvulation.plusDays(1)
|
val nextStart = projectedStart.plusDays(avgCycleLength.toLong())
|
||||||
val cycleEnd = cycleStart.plusDays(avgLength.toLong())
|
markCycleWindow(
|
||||||
val periodEnd = cycleStart.plusDays(4)
|
map = map,
|
||||||
|
cycleStart = projectedStart,
|
||||||
// Period days (predicted if future, already marked confirmed if past)
|
periodEnd = periodEnd,
|
||||||
var d = cycleStart
|
isPeriodConfirmed = false,
|
||||||
while (!d.isAfter(periodEnd) && !d.isAfter(rangeEnd)) {
|
nextCycleStart = nextStart,
|
||||||
if (!d.isBefore(rangeStart) && map[d] == null) {
|
avgCycleLength = avgCycleLength,
|
||||||
map[d] = CyclePhase.MENSTRUATION_PREDICTED
|
rangeStart = rangeStart,
|
||||||
}
|
rangeEnd = rangeEnd
|
||||||
d = d.plusDays(1)
|
)
|
||||||
}
|
projectedStart = nextStart
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.TrendingUp
|
import androidx.compose.material.icons.automirrored.filled.TrendingUp
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
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.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -22,15 +27,17 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.hsdiary.data.model.CyclePhase
|
import com.hsdiary.data.model.CyclePhase
|
||||||
import com.hsdiary.data.model.ProfileType
|
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.components.ProfileSwitchSheet
|
||||||
import com.hsdiary.ui.theme.*
|
import com.hsdiary.ui.theme.*
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.TextStyle
|
import java.time.format.TextStyle as JTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarScreen(
|
fun CalendarScreen(
|
||||||
onDayClick: (String) -> Unit,
|
onDayClick: (String) -> Unit,
|
||||||
@@ -41,83 +48,167 @@ fun CalendarScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val activeProfile = state.activeProfile
|
val activeProfile = state.activeProfile
|
||||||
|
val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name
|
||||||
|
|
||||||
Scaffold(
|
Column(
|
||||||
topBar = {
|
modifier = Modifier
|
||||||
TopAppBar(
|
.fillMaxSize()
|
||||||
title = {
|
.background(BgColor)
|
||||||
Column {
|
) {
|
||||||
Text("H&S Diary", style = MaterialTheme.typography.titleLarge)
|
// ── Top bar ─────────────────────────────────────────────────────────
|
||||||
if (activeProfile != null) {
|
DiaryTopBar(
|
||||||
Text(
|
subtitle = activeProfile?.name,
|
||||||
activeProfile.name,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onTrendsClick) {
|
IconButton(
|
||||||
Icon(Icons.AutoMirrored.Filled.TrendingUp, contentDescription = "Trends")
|
onClick = onTrendsClick,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.TrendingUp,
|
||||||
|
contentDescription = "Trends",
|
||||||
|
tint = FgMutedColor,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onSettingsClick) {
|
IconButton(
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
onClick = onSettingsClick,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Settings,
|
||||||
|
contentDescription = "Settings",
|
||||||
|
tint = FgMutedColor,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (activeProfile != null) {
|
if (activeProfile != null) {
|
||||||
val avatarColor = parseColor(activeProfile.avatarColor)
|
Box(
|
||||||
IconButton(onClick = { viewModel.showProfileSheet() }) {
|
modifier = Modifier
|
||||||
AvatarDot(name = activeProfile.name, avatarColor = avatarColor, size = 32)
|
.size(30.dp)
|
||||||
|
.background(parseColor(activeProfile.avatarColor), CircleShape)
|
||||||
|
.clickable { viewModel.showProfileSheet() },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = activeProfile.name.take(1).uppercase(),
|
||||||
|
fontFamily = InstrumentSans,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = BgColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
) { padding ->
|
// ── Cycle status banner (female profiles only) ───────────────────────
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
if (isFemale) {
|
||||||
// Context banner (female only)
|
CycleStatusBanner(
|
||||||
if (activeProfile?.profileType == ProfileType.FEMALE.name) {
|
prediction = state.prediction,
|
||||||
val prediction = state.prediction
|
|
||||||
ContextBanner(
|
|
||||||
prediction = prediction,
|
|
||||||
onClick = onInsightsClick
|
onClick = onInsightsClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Month navigation
|
// ── Month navigation ─────────────────────────────────────────────────
|
||||||
MonthHeader(
|
Row(
|
||||||
month = state.currentMonth,
|
modifier = Modifier
|
||||||
onPrevious = viewModel::previousMonth,
|
.fillMaxWidth()
|
||||||
onNext = viewModel::nextMonth
|
.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
|
// ── Day-of-week headers ──────────────────────────────────────────────
|
||||||
val dayHeaders = if (state.firstDayOfWeek == 1)
|
val dowLabels = if (state.firstDayOfWeek == 1)
|
||||||
listOf("S", "M", "T", "W", "T", "F", "S")
|
listOf("S", "M", "T", "W", "T", "F", "S")
|
||||||
else
|
else
|
||||||
listOf("M", "T", "W", "T", "F", "S", "S")
|
listOf("M", "T", "W", "T", "F", "S", "S")
|
||||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
|
Row(
|
||||||
dayHeaders.forEach { h ->
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
) {
|
||||||
|
dowLabels.forEach { label ->
|
||||||
Text(
|
Text(
|
||||||
text = h,
|
text = label,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
fontFamily = JetBrainsMono,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
fontSize = 10.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 1.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calendar grid
|
// ── Calendar grid ────────────────────────────────────────────────────
|
||||||
val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name
|
Column(
|
||||||
CalendarGrid(
|
modifier = Modifier
|
||||||
days = state.dayStates,
|
.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,
|
isFemale = isFemale,
|
||||||
onDayClick = { onDayClick(it.toString()) }
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
onClick = { onDayClick(day.date.toString()) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Today footer ─────────────────────────────────────────────────────
|
||||||
|
val today = state.dayStates.firstOrNull { it.isToday }
|
||||||
|
if (today != null) {
|
||||||
|
TodayFooter(
|
||||||
|
today = today,
|
||||||
|
isFemale = isFemale,
|
||||||
|
onTap = { onDayClick(today.date.toString()) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile switch sheet
|
||||||
if (state.showProfileSheet && state.profiles.size > 1) {
|
if (state.showProfileSheet && state.profiles.size > 1) {
|
||||||
ProfileSwitchSheet(
|
ProfileSwitchSheet(
|
||||||
profiles = viewModel.getProfileSwitchItems(state),
|
profiles = viewModel.getProfileSwitchItems(state),
|
||||||
@@ -127,183 +218,227 @@ fun CalendarScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Cycle status banner ─────────────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContextBanner(
|
private fun CycleStatusBanner(prediction: CyclePrediction?, onClick: () -> Unit) {
|
||||||
prediction: com.hsdiary.domain.model.CyclePrediction?,
|
val (dotColor, phaseText, subtitle) = when {
|
||||||
onClick: () -> Unit
|
prediction == null || prediction.cyclesLogged == 0 -> Triple(
|
||||||
) {
|
FgFaintColor,
|
||||||
val text = when {
|
"Start tracking your cycle",
|
||||||
prediction == null || prediction.cyclesLogged == 0 ->
|
"Log your first period to begin"
|
||||||
"🩸 Set up your cycle — log your first period to begin"
|
)
|
||||||
prediction.currentPhase == CyclePhase.MENSTRUATION_CONFIRMED ||
|
prediction.currentPhase == CyclePhase.MENSTRUATION_CONFIRMED -> Triple(
|
||||||
prediction.currentPhase == CyclePhase.MENSTRUATION_PREDICTED ->
|
PeriodColor,
|
||||||
"🩸 Period · Day ${prediction.currentCycleDay}"
|
"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 ||
|
||||||
prediction.currentPhase == CyclePhase.OVULATION_PREDICTED ->
|
prediction.currentPhase == CyclePhase.OVULATION_PREDICTED -> Triple(
|
||||||
"🌿 Ovulation day"
|
OvulationColor,
|
||||||
|
"Ovulation day",
|
||||||
|
"Cycle day ${prediction.currentCycleDay} · next period in ~${prediction.daysUntilNextPeriod ?: "?"} days"
|
||||||
|
)
|
||||||
prediction.currentPhase == CyclePhase.FERTILE_WINDOW ||
|
prediction.currentPhase == CyclePhase.FERTILE_WINDOW ||
|
||||||
prediction.currentPhase == CyclePhase.FERTILE_WINDOW_PREDICTED -> {
|
prediction.currentPhase == CyclePhase.FERTILE_WINDOW_PREDICTED -> Triple(
|
||||||
val remaining = prediction.fertileWindowEnd?.let {
|
FertileColor,
|
||||||
(it.toEpochDay() - LocalDate.now().toEpochDay()).toInt()
|
"Fertile window",
|
||||||
} ?: 0
|
"Cycle day ${prediction.currentCycleDay}" +
|
||||||
"🌿 Fertile window · ~$remaining days remaining"
|
(prediction.daysUntilNextPeriod?.let { " · next period in ~$it days" } ?: "")
|
||||||
}
|
)
|
||||||
else -> {
|
else -> Triple(
|
||||||
val days = prediction.daysUntilNextPeriod
|
LutealColor,
|
||||||
if (days != null) "🩸 Next period in ~$days days · Cycle day ${prediction.currentCycleDay}"
|
"Cycle day ${prediction.currentCycleDay}",
|
||||||
else "🩸 Cycle day ${prediction.currentCycleDay}"
|
prediction.daysUntilNextPeriod?.let { "Next period in ~$it days" } ?: "Tracking"
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MonthHeader(month: YearMonth, onPrevious: () -> Unit, onNext: () -> Unit) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onPrevious) {
|
|
||||||
Icon(Icons.Default.ChevronLeft, contentDescription = "Previous month")
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "${month.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${month.year}",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
IconButton(onClick = onNext) {
|
|
||||||
Icon(Icons.Default.ChevronRight, contentDescription = "Next month")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CalendarGrid(
|
|
||||||
days: List<DayState>,
|
|
||||||
isFemale: Boolean,
|
|
||||||
onDayClick: (LocalDate) -> Unit
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) {
|
|
||||||
days.chunked(7).forEach { week ->
|
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
week.forEach { day ->
|
|
||||||
DayCell(
|
|
||||||
day = day,
|
|
||||||
isFemale = isFemale,
|
|
||||||
onClick = { onDayClick(day.date) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DayCell(
|
|
||||||
day: DayState,
|
|
||||||
isFemale: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val phaseColor = if (isFemale) phaseColor(day.phase) else null
|
|
||||||
val textAlpha = if (day.isCurrentMonth) 1f else 0.35f
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.padding(1.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
) {
|
|
||||||
// Phase color band at bottom
|
|
||||||
if (phaseColor != null) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.25f)
|
.clickable(onClick = onClick)
|
||||||
.align(Alignment.BottomCenter)
|
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||||
.background(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
phaseColor.copy(
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
alpha = if (isPredicted(day.phase)) 0.45f else 0.75f
|
) {
|
||||||
)
|
GlowDot(color = dotColor)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = phaseText,
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 19.sp,
|
||||||
|
color = FgColor
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle.uppercase(),
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.5.sp,
|
||||||
|
color = FgSubtleColor,
|
||||||
|
letterSpacing = 0.63.sp,
|
||||||
|
modifier = Modifier.padding(top = 3.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text(text = "›", fontFamily = CormorantGaramond, fontSize = 18.sp, color = FgFaintColor)
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
|
}
|
||||||
|
|
||||||
// Today ring
|
// ─── Day cell ────────────────────────────────────────────────────────────────
|
||||||
if (day.isToday) {
|
@Composable
|
||||||
Box(
|
private fun AubergineDay(
|
||||||
modifier = Modifier
|
day: DayState,
|
||||||
.size(28.dp)
|
isFemale: Boolean,
|
||||||
.clip(CircleShape)
|
modifier: Modifier = Modifier,
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
onClick: () -> Unit
|
||||||
.align(Alignment.TopCenter)
|
) {
|
||||||
.offset(y = 3.dp)
|
val phase = if (isFemale) phaseColorForDot(day.phase) else null
|
||||||
)
|
val isPeriodConfirmed = day.phase == CyclePhase.MENSTRUATION_CONFIRMED
|
||||||
|
val isPredicted = isFemale && isPredicted(day.phase)
|
||||||
|
val icons = buildIconList(day, isFemale)
|
||||||
|
|
||||||
|
// Ring style visual
|
||||||
|
val bubbleBg: Color = if (isPeriodConfirmed) (phase ?: Color.Transparent) else Color.Transparent
|
||||||
|
val bubbleBorderColor: Color = when {
|
||||||
|
day.isToday && !isPeriodConfirmed -> AccentColor
|
||||||
|
phase != null && !isPeriodConfirmed ->
|
||||||
|
if (isPredicted) phase.copy(alpha = 0.55f) else phase
|
||||||
|
isPeriodConfirmed -> phase ?: Color.Transparent
|
||||||
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
|
val bubbleBorderWidth = if (day.isToday) 1.5.dp else 1.dp
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
modifier = modifier
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.alpha(if (day.isCurrentMonth) 1f else 0.35f)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
// Day number
|
// Number bubble
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(30.dp)
|
||||||
|
.background(bubbleBg, CircleShape)
|
||||||
|
.then(
|
||||||
|
if (bubbleBorderColor != Color.Transparent)
|
||||||
|
Modifier.border(bubbleBorderWidth, bubbleBorderColor, CircleShape)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = day.date.dayOfMonth.toString(),
|
text = day.date.dayOfMonth.toString(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontFamily = JetBrainsMono,
|
||||||
color = if (day.isToday) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha),
|
fontSize = 12.sp,
|
||||||
fontWeight = if (day.isToday) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (isPeriodConfirmed) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
color = when {
|
||||||
|
isPredicted -> phase ?: FgColor
|
||||||
|
else -> FgColor
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Icon row
|
// Icon row (up to 2 condition icons + intimacy heart)
|
||||||
val icons = buildIconList(day, isFemale)
|
if (day.isCurrentMonth && (icons.isNotEmpty() || day.hasIntimacy)) {
|
||||||
if (icons.isNotEmpty()) {
|
Spacer(Modifier.height(2.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(top = 1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
horizontalArrangement = Arrangement.Center
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
val visibleIcons = if (day.hasIntimacy) icons.take(2) else icons.take(3)
|
icons.take(2).forEach { icon ->
|
||||||
val overflow = icons.size - visibleIcons.size
|
Text(icon, fontSize = 8.5.sp, lineHeight = 10.sp)
|
||||||
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) {
|
||||||
if (day.hasIntimacy) Text("❤️", fontSize = 9.sp, lineHeight = 10.sp)
|
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> {
|
private fun buildIconList(day: DayState, isFemale: Boolean): List<String> {
|
||||||
val icons = mutableListOf<String>()
|
val icons = mutableListOf<String>()
|
||||||
if (isFemale && (day.periodActive || day.phase == CyclePhase.MENSTRUATION_CONFIRMED || day.phase == CyclePhase.MENSTRUATION_PREDICTED)) {
|
day.conditionKeys.take(2).forEach { key -> icons.add(conditionIcon(key)) }
|
||||||
icons.add("🩸")
|
|
||||||
}
|
|
||||||
day.conditionKeys.take(3).forEach { key ->
|
|
||||||
icons.add(conditionIcon(key))
|
|
||||||
}
|
|
||||||
return icons
|
return icons
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun conditionIcon(key: String): String = when {
|
private fun conditionIcon(key: String): String = when {
|
||||||
key.contains("HEAD") || key.contains("MIGRAINE") -> "⚡"
|
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("NAUSEA") || key.contains("VOMIT") -> "🤢"
|
||||||
key.contains("CRAMP") -> "💫"
|
key.contains("CRAMP") -> "💫"
|
||||||
key.contains("BACK") || key.contains("NECK") || key.contains("JOINT") -> "🦴"
|
key.contains("BACK") || key.contains("NECK") || key.contains("JOINT") -> "🦴"
|
||||||
@@ -313,18 +448,15 @@ private fun conditionIcon(key: String): String = when {
|
|||||||
else -> "•"
|
else -> "•"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun phaseColor(phase: CyclePhase): Color? = when (phase) {
|
// MENSTRUATION_CONFIRMED is the only fully solid phase; all others are predicted/projected
|
||||||
CyclePhase.MENSTRUATION_CONFIRMED -> PeriodColor
|
private fun isPredicted(phase: CyclePhase): Boolean = when (phase) {
|
||||||
CyclePhase.MENSTRUATION_PREDICTED -> PeriodPredictedColor
|
CyclePhase.MENSTRUATION_CONFIRMED -> false
|
||||||
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> FertileColor
|
else -> true
|
||||||
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> OvulationColor
|
|
||||||
CyclePhase.LUTEAL -> LutealColor
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
fun parseColor(hex: String): Color = try {
|
||||||
Color(android.graphics.Color.parseColor(hex))
|
Color(android.graphics.Color.parseColor(hex))
|
||||||
} catch (e: Exception) { Color(0xFFE91E63) }
|
} catch (e: Exception) {
|
||||||
|
AccentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,10 @@ fun AvatarDot(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = name.take(1).uppercase(),
|
text = name.take(1).uppercase(),
|
||||||
color = Color.White,
|
// Design spec: dark near-black initial on all avatar colours (th.accentOn)
|
||||||
fontSize = (size * 0.38).sp,
|
color = Color(0xFF1C1015),
|
||||||
fontWeight = FontWeight.Bold
|
fontSize = (size * 0.40).sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package com.hsdiary.ui.components
|
package com.hsdiary.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.hsdiary.ui.theme.*
|
||||||
|
|
||||||
data class ProfileSwitchItem(
|
data class ProfileSwitchItem(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@@ -25,14 +31,47 @@ fun ProfileSwitchSheet(
|
|||||||
) {
|
) {
|
||||||
var confirmTarget by remember { mutableStateOf<ProfileSwitchItem?>(null) }
|
var confirmTarget by remember { mutableStateOf<ProfileSwitchItem?>(null) }
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
ModalBottomSheet(
|
||||||
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp)) {
|
onDismissRequest = onDismiss,
|
||||||
Text(
|
containerColor = SurfaceColor,
|
||||||
text = "Switch Profile",
|
dragHandle = {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Box(
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
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 ->
|
profiles.forEach { profile ->
|
||||||
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -40,38 +79,112 @@ fun ProfileSwitchSheet(
|
|||||||
if (!profile.isActive) confirmTarget = profile
|
if (!profile.isActive) confirmTarget = profile
|
||||||
else onDismiss()
|
else onDismiss()
|
||||||
}
|
}
|
||||||
.padding(vertical = 12.dp),
|
.padding(vertical = 14.dp, horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
) {
|
) {
|
||||||
AvatarDot(name = profile.name, avatarColor = profile.avatarColor)
|
AvatarDot(
|
||||||
|
name = profile.name,
|
||||||
|
avatarColor = profile.avatarColor,
|
||||||
|
size = 36
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = profile.name,
|
text = profile.name,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
fontFamily = CormorantGaramond,
|
||||||
modifier = Modifier.weight(1f)
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
color = FgColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (profile.isActive) "CURRENTLY OPEN" else "TAP TO SWITCH",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
)
|
)
|
||||||
if (profile.isActive) {
|
|
||||||
Badge { Text("Active") }
|
|
||||||
}
|
}
|
||||||
|
if (profile.isActive) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color.Transparent, RoundedCornerShape(999.dp))
|
||||||
|
.border(1.dp, AccentFaintColor, RoundedCornerShape(999.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"ACTIVE",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = AccentColor,
|
||||||
|
letterSpacing = 1.2.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
|
||||||
|
// Add profile button — outline style
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, BorderColor, RoundedCornerShape(999.dp))
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "+ Add a profile",
|
||||||
|
fontFamily = InstrumentSans,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = FgColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm switch dialog
|
||||||
confirmTarget?.let { target ->
|
confirmTarget?.let { target ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmTarget = null },
|
onDismissRequest = { confirmTarget = null },
|
||||||
title = { Text("Switch Profile") },
|
containerColor = SurfaceColor,
|
||||||
text = { Text("Switch to ${target.name}?") },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(
|
||||||
|
onClick = {
|
||||||
onProfileSelected(target.id)
|
onProfileSelected(target.id)
|
||||||
confirmTarget = null
|
confirmTarget = null
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}) { Text("Switch") }
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = AccentColor)
|
||||||
|
) {
|
||||||
|
Text("Switch", fontFamily = InstrumentSans)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
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
|
package com.hsdiary.ui.daydetail
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.hsdiary.data.db.entity.ConditionDefinitionEntity
|
import com.hsdiary.data.db.entity.ConditionDefinitionEntity
|
||||||
import com.hsdiary.data.db.entity.IntimacyLogEntity
|
import com.hsdiary.data.db.entity.IntimacyLogEntity
|
||||||
import com.hsdiary.data.model.ConditionCategory
|
import com.hsdiary.data.model.ConditionCategory
|
||||||
import com.hsdiary.data.model.CyclePhase
|
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.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -32,8 +43,8 @@ fun DayDetailScreen(
|
|||||||
viewModel: DayDetailViewModel = hiltViewModel()
|
viewModel: DayDetailViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
var savedSnackbar by remember { mutableStateOf(false) }
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var savedSnackbar by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(savedSnackbar) {
|
LaunchedEffect(savedSnackbar) {
|
||||||
if (savedSnackbar) {
|
if (savedSnackbar) {
|
||||||
@@ -42,8 +53,12 @@ fun DayDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val dateLabel = remember(date) {
|
val parsedDate = remember(date) { LocalDate.parse(date) }
|
||||||
LocalDate.parse(date).format(DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.getDefault()))
|
val dayOfWeek = remember(parsedDate) {
|
||||||
|
parsedDate.format(DateTimeFormatter.ofPattern("EEEE", Locale.getDefault()))
|
||||||
|
}
|
||||||
|
val subtitle = remember(parsedDate) {
|
||||||
|
parsedDate.format(DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault()))
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
@@ -55,39 +70,63 @@ fun DayDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
containerColor = BgColor,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
) { padding ->
|
||||||
TopAppBar(
|
Column(
|
||||||
title = { Text(dateLabel, style = MaterialTheme.typography.titleMedium) },
|
modifier = Modifier
|
||||||
navigationIcon = {
|
.fillMaxSize()
|
||||||
IconButton(onClick = {
|
.padding(padding)
|
||||||
|
.background(BgColor)
|
||||||
|
) {
|
||||||
|
DiaryTopBar(
|
||||||
|
showBack = true,
|
||||||
|
onBack = {
|
||||||
if (state.isDirty) viewModel.saveAndExit()
|
if (state.isDirty) viewModel.saveAndExit()
|
||||||
onBack()
|
onBack()
|
||||||
}) {
|
},
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
title = dayOfWeek,
|
||||||
|
subtitle = buildTopBarSubtitle(subtitle, state),
|
||||||
|
actions = {
|
||||||
|
if (state.isDirty) {
|
||||||
|
Text(
|
||||||
|
"SAVING",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = AccentDimColor,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"SAVED",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 32.dp)
|
contentPadding = PaddingValues(bottom = 32.dp)
|
||||||
) {
|
) {
|
||||||
// Cycle section (female only)
|
// I. Cycle (female only)
|
||||||
if (state.isFemale) {
|
if (state.isFemale) {
|
||||||
item {
|
item {
|
||||||
|
SectionLabel(title = "Cycle", num = "I.")
|
||||||
CycleSection(
|
CycleSection(
|
||||||
state = state,
|
state = state,
|
||||||
onTogglePeriodStart = viewModel::togglePeriodStart,
|
onTogglePeriodStart = viewModel::togglePeriodStart,
|
||||||
onTogglePeriodEnd = viewModel::togglePeriodEnd
|
onTogglePeriodEnd = viewModel::togglePeriodEnd
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { HorizontalDivider() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditions section
|
// II. Symptoms
|
||||||
item {
|
item {
|
||||||
|
SectionLabel(title = "Symptoms", num = if (state.isFemale) "II." else "I.")
|
||||||
ConditionsSection(
|
ConditionsSection(
|
||||||
definitions = state.definitions,
|
definitions = state.definitions,
|
||||||
selectedConditions = state.conditions,
|
selectedConditions = state.conditions,
|
||||||
@@ -96,18 +135,20 @@ fun DayDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notes
|
// III. Notes
|
||||||
item {
|
item {
|
||||||
|
SectionLabel(title = "Notes", num = if (state.isFemale) "III." else "II.")
|
||||||
NotesSection(
|
NotesSection(
|
||||||
notes = state.notes,
|
notes = state.notes,
|
||||||
onNotesChange = viewModel::updateNotes
|
onNotesChange = viewModel::updateNotes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) }
|
item { OrnamentDivider() }
|
||||||
|
|
||||||
// Intimacy section
|
// IV. Intimacy
|
||||||
item {
|
item {
|
||||||
|
SectionLabel(title = "Intimacy", num = if (state.isFemale) "IV." else "III.")
|
||||||
IntimacySection(
|
IntimacySection(
|
||||||
logs = state.intimacyLogs,
|
logs = state.intimacyLogs,
|
||||||
profiles = state.allProfiles,
|
profiles = state.allProfiles,
|
||||||
@@ -125,14 +166,29 @@ fun DayDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
private enum class CycleDayUiState {
|
||||||
NO_DATA, // State 1: no data for this day → show Started (untapped)
|
NO_DATA, START_CONFIRMED, MID_OPEN, END_CONFIRMED, BETWEEN_CYCLES, PREDICTED
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cycleDayUiState(state: DayDetailUiState): CycleDayUiState = when {
|
private fun cycleDayUiState(state: DayDetailUiState): CycleDayUiState = when {
|
||||||
@@ -153,41 +209,43 @@ private fun CycleSection(
|
|||||||
) {
|
) {
|
||||||
val cycleState = cycleDayUiState(state)
|
val cycleState = cycleDayUiState(state)
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
// Phase / cycle-day header
|
// Phase description — italic serif, phase colour
|
||||||
val phaseLabel = when (state.currentPhase) {
|
val phaseDescription = when (state.currentPhase) {
|
||||||
CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation"
|
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED ->
|
||||||
CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation"
|
"Peak fertility — narrow window today."
|
||||||
CyclePhase.FERTILE_WINDOW,
|
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED ->
|
||||||
CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window"
|
"Fertile window open."
|
||||||
CyclePhase.OVULATION,
|
CyclePhase.MENSTRUATION_CONFIRMED ->
|
||||||
CyclePhase.OVULATION_PREDICTED -> "Ovulation"
|
"Period · day ${state.periodDayNumber.takeIf { it > 0 } ?: ""}."
|
||||||
CyclePhase.LUTEAL -> "Luteal Phase"
|
CyclePhase.MENSTRUATION_PREDICTED ->
|
||||||
CyclePhase.FOLLICULAR -> "Follicular Phase"
|
"Period predicted today."
|
||||||
|
CyclePhase.LUTEAL ->
|
||||||
|
"Luteal phase."
|
||||||
|
CyclePhase.FOLLICULAR ->
|
||||||
|
"Follicular phase."
|
||||||
else -> null
|
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(
|
Text(
|
||||||
"Cycle day ${state.cycleDay} · $phaseLabel",
|
text = phaseDescription,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
fontFamily = CormorantGaramond,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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
|
// Period action chips
|
||||||
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))
|
|
||||||
|
|
||||||
val showStarted = cycleState == CycleDayUiState.NO_DATA ||
|
val showStarted = cycleState == CycleDayUiState.NO_DATA ||
|
||||||
cycleState == CycleDayUiState.START_CONFIRMED ||
|
cycleState == CycleDayUiState.START_CONFIRMED ||
|
||||||
cycleState == CycleDayUiState.PREDICTED
|
cycleState == CycleDayUiState.PREDICTED
|
||||||
@@ -195,59 +253,85 @@ private fun CycleSection(
|
|||||||
cycleState == CycleDayUiState.END_CONFIRMED
|
cycleState == CycleDayUiState.END_CONFIRMED
|
||||||
|
|
||||||
if (showStarted || showEnded) {
|
if (showStarted || showEnded) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
if (showStarted) {
|
if (showStarted) {
|
||||||
FilterChip(
|
AubergineFilterChip(
|
||||||
selected = cycleState == CycleDayUiState.START_CONFIRMED,
|
selected = cycleState == CycleDayUiState.START_CONFIRMED,
|
||||||
onClick = onTogglePeriodStart,
|
onClick = onTogglePeriodStart,
|
||||||
label = { Text("Period started") },
|
label = "Period started",
|
||||||
leadingIcon = {
|
leadingIcon = if (cycleState == CycleDayUiState.START_CONFIRMED) "✓" else "▶"
|
||||||
Icon(
|
|
||||||
if (cycleState == CycleDayUiState.START_CONFIRMED) Icons.Default.Check
|
|
||||||
else Icons.Default.PlayArrow,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showEnded) {
|
if (showEnded) {
|
||||||
FilterChip(
|
AubergineFilterChip(
|
||||||
selected = cycleState == CycleDayUiState.END_CONFIRMED,
|
selected = cycleState == CycleDayUiState.END_CONFIRMED,
|
||||||
onClick = onTogglePeriodEnd,
|
onClick = onTogglePeriodEnd,
|
||||||
label = { Text("Period ended") },
|
label = "Period ended",
|
||||||
leadingIcon = {
|
leadingIcon = if (cycleState == CycleDayUiState.END_CONFIRMED) "✓" else "■"
|
||||||
Icon(
|
|
||||||
if (cycleState == CycleDayUiState.END_CONFIRMED) Icons.Default.Check
|
|
||||||
else Icons.Default.Stop,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Predicted-period nudge (State 6 only)
|
// Predicted nudge
|
||||||
if (cycleState == CycleDayUiState.PREDICTED) {
|
if (cycleState == CycleDayUiState.PREDICTED) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.fillMaxWidth()
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
.background(AccentFaintColor, RoundedCornerShape(10.dp))
|
||||||
|
.border(1.dp, AccentFaintColor, RoundedCornerShape(10.dp))
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Info, contentDescription = null,
|
Text("🩸", fontSize = 14.sp)
|
||||||
tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
Text(
|
||||||
Text("Period predicted today — tap \"Period started\" to confirm",
|
"Period predicted today — tap \"Period started\" to confirm",
|
||||||
style = MaterialTheme.typography.bodySmall)
|
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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConditionsSection(
|
private fun ConditionsSection(
|
||||||
@@ -256,21 +340,17 @@ private fun ConditionsSection(
|
|||||||
onToggle: (String) -> Unit,
|
onToggle: (String) -> Unit,
|
||||||
onRatingChange: (String, Int) -> Unit
|
onRatingChange: (String, Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val grouped = remember(definitions) {
|
val grouped = remember(definitions) { definitions.groupBy { it.category } }
|
||||||
definitions.groupBy { it.category }
|
|
||||||
}
|
|
||||||
var expandedCategories by remember { mutableStateOf(setOf<String>()) }
|
var expandedCategories by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
Text("Symptoms", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
grouped.forEach { (category, items) ->
|
grouped.forEach { (category, items) ->
|
||||||
val categoryLabel = ConditionCategory.entries.find { it.name == category }?.displayName
|
val categoryLabel = ConditionCategory.entries.find { it.name == category }?.displayName
|
||||||
?: category.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() }
|
?: category.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() }
|
||||||
val isExpanded = category in expandedCategories
|
val isExpanded = category in expandedCategories
|
||||||
val hasSelected = items.any { selectedConditions.containsKey(it.conditionKey) }
|
val selectedCount = items.count { selectedConditions.containsKey(it.conditionKey) }
|
||||||
|
|
||||||
|
// Category row
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -280,83 +360,116 @@ private fun ConditionsSection(
|
|||||||
else
|
else
|
||||||
expandedCategories + category
|
expandedCategories + category
|
||||||
}
|
}
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = categoryLabel,
|
text = categoryLabel.uppercase(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
fontFamily = InstrumentSans,
|
||||||
color = if (hasSelected) MaterialTheme.colorScheme.primary
|
fontWeight = FontWeight.Medium,
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
fontSize = 12.sp,
|
||||||
|
letterSpacing = 0.6.sp,
|
||||||
|
color = if (selectedCount > 0 || isExpanded) FgColor else FgSubtleColor,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
if (hasSelected) {
|
if (selectedCount > 0) {
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
Box(
|
||||||
Text("${items.count { selectedConditions.containsKey(it.conditionKey) }}")
|
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(
|
Text(
|
||||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
text = if (isExpanded) "−" else "+",
|
||||||
contentDescription = null,
|
fontFamily = JetBrainsMono,
|
||||||
modifier = Modifier.size(20.dp)
|
fontSize = 12.sp,
|
||||||
|
color = FgFaintColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expanded content
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
items.forEach { def ->
|
items.forEach { def ->
|
||||||
val selected = selectedConditions.containsKey(def.conditionKey)
|
val selected = selectedConditions.containsKey(def.conditionKey)
|
||||||
FilterChip(
|
AubergineFilterChip(
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onClick = { onToggle(def.conditionKey) },
|
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 ->
|
items.filter { selectedConditions.containsKey(it.conditionKey) }.forEach { def ->
|
||||||
val rating = selectedConditions[def.conditionKey] ?: 3
|
val rating = selectedConditions[def.conditionKey] ?: 3
|
||||||
RatingRow(
|
AubergineRatingRow(
|
||||||
label = def.displayName,
|
label = def.displayName,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
onRatingChange = { onRatingChange(def.conditionKey, it) }
|
onRatingChange = { onRatingChange(def.conditionKey, it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit) {
|
private fun AubergineRatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
(1..5).forEach { i ->
|
(1..5).forEach { i ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(22.dp)
|
.size(20.dp)
|
||||||
.background(
|
.background(
|
||||||
if (i <= rating) MaterialTheme.colorScheme.primary
|
if (i <= rating) AccentColor else Color.Transparent,
|
||||||
else MaterialTheme.colorScheme.surfaceVariant,
|
RoundedCornerShape(4.dp)
|
||||||
shape = RoundedCornerShape(4.dp)
|
)
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
if (i <= rating) AccentColor else BorderColor,
|
||||||
|
RoundedCornerShape(4.dp)
|
||||||
)
|
)
|
||||||
.clickable { onRatingChange(i) },
|
.clickable { onRatingChange(i) },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
i.toString(),
|
text = i.toString(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
fontFamily = JetBrainsMono,
|
||||||
color = if (i <= rating) MaterialTheme.colorScheme.onPrimary
|
fontSize = 10.sp,
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (i <= rating) AccentOnColor else FgFaintColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,21 +477,61 @@ private fun RatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Notes section ────────────────────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
private fun NotesSection(notes: String, onNotesChange: (String) -> Unit) {
|
private fun NotesSection(notes: String, onNotesChange: (String) -> Unit) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
OutlinedTextField(
|
// Serif italic textarea card
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(SurfaceColor, RoundedCornerShape(10.dp))
|
||||||
|
.border(1.dp, BorderSoftColor, RoundedCornerShape(10.dp))
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
value = notes,
|
value = notes,
|
||||||
onValueChange = { if (it.length <= 256) onNotesChange(it) },
|
onValueChange = { if (it.length <= 256) onNotesChange(it) },
|
||||||
label = { Text("Notes") },
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
fontFamily = CormorantGaramond,
|
||||||
minLines = 2,
|
fontStyle = FontStyle.Italic,
|
||||||
maxLines = 4,
|
fontSize = 14.sp,
|
||||||
supportingText = { Text("${notes.length}/256") }
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun IntimacySection(
|
private fun IntimacySection(
|
||||||
@@ -389,54 +542,44 @@ private fun IntimacySection(
|
|||||||
onAdd: (String, String?, String?, Boolean) -> Unit,
|
onAdd: (String, String?, String?, Boolean) -> Unit,
|
||||||
onDelete: (IntimacyLogEntity) -> Unit
|
onDelete: (IntimacyLogEntity) -> Unit
|
||||||
) {
|
) {
|
||||||
var isExpanded by remember { mutableStateOf(true) }
|
|
||||||
var showAddForm by remember { mutableStateOf(false) }
|
var showAddForm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)) {
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { isExpanded = !isExpanded }
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Intimacy",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
logs.forEach { log ->
|
logs.forEach { log ->
|
||||||
IntimacyLogCard(
|
IntimacyLogCard(
|
||||||
log = log,
|
log = log,
|
||||||
isFemaleInFertileWindow = isFemaleInFertileWindow,
|
isFemaleInFertileWindow = isFemaleInFertileWindow,
|
||||||
onDelete = { onDelete(log) }
|
onDelete = { onDelete(log) }
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
TextButton(
|
DiaryTextLink(
|
||||||
onClick = { showAddForm = true },
|
onClick = { showAddForm = true },
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
leading = "+"
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
Text(
|
||||||
Spacer(Modifier.width(4.dp))
|
"Add encounter",
|
||||||
Text("Add encounter")
|
fontFamily = InstrumentSans,
|
||||||
}
|
fontSize = 13.sp,
|
||||||
|
color = AccentColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom sheet keeps the form above the keyboard and always fully visible
|
|
||||||
if (showAddForm) {
|
if (showAddForm) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showAddForm = false },
|
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(
|
AddIntimacyForm(
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
@@ -457,26 +600,51 @@ private fun IntimacyLogCard(
|
|||||||
isFemaleInFertileWindow: Boolean,
|
isFemaleInFertileWindow: Boolean,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
DiaryCard(accent = true, padding = 12.dp) {
|
||||||
Row(
|
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"}",
|
Text("❤", fontSize = 14.sp, color = AccentColor)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Close, contentDescription = "Delete", modifier = Modifier.size(14.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isFemaleInFertileWindow && !log.protected) {
|
|
||||||
Text(
|
Text(
|
||||||
"🌿 Unprotected · Fertile window",
|
text = buildString {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
append(log.participantName ?: log.participantType)
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
if (log.timeOfDay != null) append(" · ${log.timeOfDay}")
|
||||||
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)
|
},
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,57 +670,86 @@ private fun AddIntimacyForm(
|
|||||||
.padding(bottom = 32.dp),
|
.padding(bottom = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.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
|
// 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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
if (otherProfile != null) {
|
if (otherProfile != null) {
|
||||||
FilterChip(
|
AubergineFilterChip(
|
||||||
selected = participantType == "PARTNER",
|
selected = participantType == "PARTNER",
|
||||||
onClick = { participantType = "PARTNER"; participantName = otherProfile.name },
|
onClick = { participantType = "PARTNER"; participantName = otherProfile.name },
|
||||||
label = { Text(otherProfile.name) }
|
label = otherProfile.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FilterChip(
|
AubergineFilterChip(
|
||||||
selected = participantType == "OTHER",
|
selected = participantType == "OTHER",
|
||||||
onClick = { participantType = "OTHER"; participantName = "" },
|
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") {
|
if (participantType == "OTHER") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = participantName,
|
value = participantName,
|
||||||
onValueChange = { if (it.length <= 32) participantName = it },
|
onValueChange = { if (it.length <= 32) participantName = it },
|
||||||
label = { Text("Name (optional)") },
|
label = { Text("Name (optional)", fontFamily = InstrumentSans, fontSize = 12.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = outlinedTextFieldColors()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = timeOfDay,
|
value = timeOfDay,
|
||||||
onValueChange = { timeOfDay = it },
|
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,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = outlinedTextFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text("Protected", style = MaterialTheme.typography.bodyMedium)
|
Text("Protected", fontFamily = InstrumentSans, fontSize = 14.sp, color = FgColor)
|
||||||
Text(
|
Text(
|
||||||
if (isProtected) "Contraception used" else "No contraception",
|
if (isProtected) "Contraception used" else "No contraception",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontFamily = InstrumentSans,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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)) {
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
onConfirm(
|
onConfirm(
|
||||||
@@ -562,13 +759,29 @@ private fun AddIntimacyForm(
|
|||||||
isProtected
|
isProtected
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
) { Text("Save encounter") }
|
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
|
@Composable
|
||||||
private fun BackHandler(onBack: () -> Unit) {
|
private fun BackHandler(onBack: () -> Unit) {
|
||||||
androidx.activity.compose.BackHandler(onBack = onBack)
|
androidx.activity.compose.BackHandler(onBack = onBack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.hsdiary.ui.insights
|
package com.hsdiary.ui.insights
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
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.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.hsdiary.data.model.CyclePhase
|
import com.hsdiary.data.model.CyclePhase
|
||||||
import com.hsdiary.domain.model.CyclePrediction
|
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.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CycleInsightsScreen(
|
fun CycleInsightsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -29,179 +39,476 @@ fun CycleInsightsScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Column(
|
||||||
topBar = {
|
modifier = Modifier
|
||||||
TopAppBar(
|
.fillMaxSize()
|
||||||
title = { Text("Cycle Insights") },
|
.background(BgColor)
|
||||||
navigationIcon = {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
DiaryTopBar(
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
showBack = true,
|
||||||
}
|
onBack = onBack,
|
||||||
}
|
title = "Cycle insights",
|
||||||
|
subtitle = if (state.prediction != null)
|
||||||
|
"${state.prediction!!.cyclesLogged} cycles · Tier ${state.prediction!!.tier} prediction"
|
||||||
|
else null
|
||||||
)
|
)
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator(color = AccentColor)
|
||||||
}
|
}
|
||||||
return@Scaffold
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
val prediction = state.prediction
|
val prediction = state.prediction
|
||||||
if (prediction == null || prediction.cyclesLogged == 0) {
|
if (prediction == null || prediction.cyclesLogged == 0) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().padding(padding).padding(32.dp),
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("🩸", style = MaterialTheme.typography.displayMedium)
|
Text("🩸", fontSize = 40.sp)
|
||||||
Spacer(Modifier.height(16.dp))
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Log your first period on the calendar to begin tracking.",
|
"Log your first period on the calendar to begin tracking.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
fontFamily = InstrumentSans,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
fontSize = 14.sp,
|
||||||
|
color = FgMutedColor,
|
||||||
|
lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return@Scaffold
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
// Current phase card
|
// ── I. Today ───────────────────────────────────────────────────────
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
SectionLabel(title = "Today", num = "I.")
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
Text("Current Cycle", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
val phaseText = when (prediction.currentPhase) {
|
|
||||||
CyclePhase.MENSTRUATION_CONFIRMED, CyclePhase.MENSTRUATION_PREDICTED -> "🩸 Menstruation"
|
|
||||||
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "🌿 Fertile Window"
|
|
||||||
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "🌿 Ovulation Day"
|
|
||||||
CyclePhase.LUTEAL -> "🌙 Luteal Phase"
|
|
||||||
CyclePhase.FOLLICULAR -> "🌱 Follicular Phase"
|
|
||||||
else -> "—"
|
|
||||||
}
|
|
||||||
Text(phaseText, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
|
||||||
if (prediction.currentCycleDay > 0) {
|
|
||||||
Text("Day ${prediction.currentCycleDay}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats row
|
// Phase name — large serif italic
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
val phaseName = when (prediction.currentPhase) {
|
||||||
StatCard(
|
CyclePhase.MENSTRUATION_CONFIRMED,
|
||||||
label = "Avg Cycle",
|
CyclePhase.MENSTRUATION_PREDICTED -> "Menstruation."
|
||||||
value = "${prediction.averageCycleLength} days",
|
CyclePhase.FERTILE_WINDOW,
|
||||||
modifier = Modifier.weight(1f)
|
CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile window."
|
||||||
)
|
CyclePhase.OVULATION,
|
||||||
StatCard(
|
CyclePhase.OVULATION_PREDICTED -> "Ovulation."
|
||||||
label = "Cycles Logged",
|
CyclePhase.LUTEAL -> "Luteal phase."
|
||||||
value = "${prediction.cyclesLogged}",
|
CyclePhase.FOLLICULAR -> "Follicular phase."
|
||||||
modifier = Modifier.weight(1f)
|
else -> "Tracking."
|
||||||
)
|
|
||||||
StatCard(
|
|
||||||
label = "Prediction",
|
|
||||||
value = "Tier ${prediction.tier}",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
text = phaseName,
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 30.sp,
|
||||||
|
lineHeight = 32.sp,
|
||||||
|
color = FgColor
|
||||||
|
)
|
||||||
|
|
||||||
// Next period
|
// Subtitle
|
||||||
prediction.nextPeriodDate?.let { next ->
|
val phaseSubtitle = when (prediction.currentPhase) {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED ->
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
prediction.nextPeriodDate?.let {
|
||||||
Text("Upcoming", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault())
|
val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault())
|
||||||
Text("Next period: ${next.format(fmt)}", style = MaterialTheme.typography.bodyLarge)
|
"A single peak day. Fertile window narrowing — next period predicted for ${it.format(fmt)}."
|
||||||
prediction.daysUntilNextPeriod?.let { days ->
|
} ?: "A single peak day."
|
||||||
Text("In ~$days days", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED ->
|
||||||
|
"Elevated fertility. Track closely."
|
||||||
|
CyclePhase.MENSTRUATION_CONFIRMED ->
|
||||||
|
"Period day ${prediction.currentCycleDay}."
|
||||||
|
else -> prediction.daysUntilNextPeriod?.let { "Next period in ~$it days." } ?: ""
|
||||||
}
|
}
|
||||||
|
if (phaseSubtitle.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = phaseSubtitle,
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
color = FgMutedColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase arc
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
PhaseArc(
|
||||||
|
cycleDay = prediction.currentCycleDay,
|
||||||
|
avgLength = prediction.averageCycleLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── II. By the numbers ─────────────────────────────────────────────
|
||||||
|
SectionLabel(title = "By the numbers", num = "II.")
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
StatTile("Avg cycle", prediction.averageCycleLength.toString(), "days", Modifier.weight(1f))
|
||||||
|
StatTile("Logged", prediction.cyclesLogged.toString(), "cycles", Modifier.weight(1f))
|
||||||
|
StatTile("Tier", prediction.tier.toString(), "prediction", Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── III. Upcoming ──────────────────────────────────────────────────
|
||||||
|
SectionLabel(title = "Upcoming", num = "III.")
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
|
DiaryCard {
|
||||||
|
prediction.nextPeriodDate?.let { nextDate ->
|
||||||
|
val fmt = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Next period",
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = FgColor
|
||||||
|
)
|
||||||
|
prediction.daysUntilNextPeriod?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"IN ~$it DAYS",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = FgSubtleColor,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
nextDate.format(fmt),
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = PeriodColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"± 2 DAYS",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 0.6.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prediction.fertileWindowStart?.let { fw ->
|
prediction.fertileWindowStart?.let { fw ->
|
||||||
val fwEnd = prediction.fertileWindowEnd
|
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(
|
Text(
|
||||||
"Fertile window: ${fw.format(fmt)} – ${fwEnd?.format(fmt) ?: ""}",
|
"Fertile window",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
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) {
|
if (state.recentCycles.size >= 2) {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
SectionLabel(title = "History", num = "IV.")
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
Text("Cycle History", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
DiaryCard {
|
||||||
Spacer(Modifier.height(12.dp))
|
val lengths = state.recentCycles.mapNotNull { it.cycleLength }
|
||||||
CycleLengthBarChart(cycles = state.recentCycles.mapNotNull { it.cycleLength })
|
CycleHistoryChart(lengths = lengths)
|
||||||
}
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
Row(
|
||||||
}
|
modifier = Modifier.fillMaxWidth(),
|
||||||
}
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) {
|
|
||||||
ElevatedCard(modifier = modifier) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
"LAST ${lengths.size} CYCLES",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = FgSubtleColor,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"AVG · ${lengths.average().toInt()} D",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = FgSubtleColor,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phase arc ────────────────────────────────────────────────────────────────
|
||||||
|
@Composable
|
||||||
|
private fun PhaseArc(cycleDay: Int, avgLength: Int) {
|
||||||
|
val total = avgLength.toFloat().coerceAtLeast(1f)
|
||||||
|
val periodColor = PeriodColor
|
||||||
|
val lutealColor = LutealColor
|
||||||
|
val fertileColor = FertileColor
|
||||||
|
val predColor = PeriodPredictedColor
|
||||||
|
val ovulColor = OvulationColor
|
||||||
|
val bgColor = BgColor
|
||||||
|
|
||||||
|
data class Seg(val from: Float, val to: Float, val color: Color, val alpha: Float = 1f)
|
||||||
|
val segs = listOf(
|
||||||
|
Seg(0f, 5f / total, periodColor),
|
||||||
|
Seg(5f / total, 10f / total, lutealColor),
|
||||||
|
Seg(10f / total, 15f / total, fertileColor),
|
||||||
|
Seg(15f / total, 23f / total, lutealColor),
|
||||||
|
Seg(23f / total, 1f, predColor, 0.5f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(160.dp, 140.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.size(160.dp, 140.dp)) {
|
||||||
|
val cx = size.width / 2f
|
||||||
|
val cy = size.height * 0.5f
|
||||||
|
val r = 60.dp.toPx()
|
||||||
|
val sw = 8.dp.toPx()
|
||||||
|
|
||||||
|
segs.forEach { seg ->
|
||||||
|
val startAngle = seg.from * 360f - 90f
|
||||||
|
val sweepAngle = (seg.to - seg.from) * 360f
|
||||||
|
drawArc(
|
||||||
|
color = seg.color.copy(alpha = seg.alpha),
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = sweepAngle,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(cx - r, cy - r),
|
||||||
|
size = Size(r * 2, r * 2),
|
||||||
|
style = Stroke(width = sw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today marker dot
|
||||||
|
val angle = (cycleDay.toFloat() / total) * 2f * PI.toFloat() - PI.toFloat() / 2f
|
||||||
|
val mx = cx + r * cos(angle)
|
||||||
|
val my = cy + r * sin(angle)
|
||||||
|
drawCircle(bgColor, 7.dp.toPx(), Offset(mx, my))
|
||||||
|
drawCircle(ovulColor, 7.dp.toPx(), Offset(mx, my), style = Stroke(width = 2.dp.toPx()))
|
||||||
|
}
|
||||||
|
// Day number overlaid in centre
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = cycleDay.toString(),
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
color = FgColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "DAY",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
LegendRow(PeriodColor, "Menstruation")
|
||||||
|
LegendRow(LutealColor, "Follicular / Luteal")
|
||||||
|
LegendRow(FertileColor, "Fertile window")
|
||||||
|
LegendRow(OvulationColor, "Ovulation", today = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CycleLengthBarChart(cycles: List<Int>) {
|
private fun LegendRow(color: Color, label: String, today: Boolean = false) {
|
||||||
if (cycles.isEmpty()) return
|
Row(
|
||||||
val maxLen = cycles.max().toFloat()
|
modifier = Modifier.padding(vertical = 3.dp),
|
||||||
val barColor = MaterialTheme.colorScheme.primary
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
val avgColor = MaterialTheme.colorScheme.secondary
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
val avg = cycles.average().toFloat()
|
) {
|
||||||
|
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(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(100.dp)
|
.height(110.dp)
|
||||||
) {
|
) {
|
||||||
val barWidth = (size.width / (cycles.size * 1.5f))
|
val gap = 14.dp.toPx()
|
||||||
val spacing = barWidth * 0.5f
|
val barW = (size.width - gap * (lengths.size - 1)) / lengths.size
|
||||||
cycles.forEachIndexed { i, length ->
|
|
||||||
val barHeight = (length / maxLen) * size.height * 0.85f
|
// Average dashed line
|
||||||
val x = i * (barWidth + spacing)
|
val avgY = size.height - (avg / maxLen) * size.height * 0.9f
|
||||||
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
|
|
||||||
drawLine(
|
drawLine(
|
||||||
color = avgColor,
|
color = accentFaint,
|
||||||
start = Offset(0f, avgY),
|
start = Offset(0f, avgY),
|
||||||
end = Offset(size.width, 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))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
lengths.forEachIndexed { i, length ->
|
||||||
Text(
|
Text(
|
||||||
"Last ${cycles.size} cycles · Average: ${avg.toInt()} days",
|
text = length.toString(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
modifier = Modifier.weight(1f),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.hsdiary.data.model.ProfileType
|
import com.hsdiary.data.model.ProfileType
|
||||||
import com.hsdiary.ui.theme.AvatarColors
|
import com.hsdiary.ui.calendar.parseColor
|
||||||
|
import com.hsdiary.ui.theme.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
@@ -26,6 +32,11 @@ fun OnboardingScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(BgColor)
|
||||||
|
) {
|
||||||
when (state.step) {
|
when (state.step) {
|
||||||
0 -> WelcomeStep(onNext = { viewModel.nextStep() })
|
0 -> WelcomeStep(onNext = { viewModel.nextStep() })
|
||||||
1 -> ProfileSetupStep(
|
1 -> ProfileSetupStep(
|
||||||
@@ -55,35 +66,174 @@ fun OnboardingScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Welcome step ─────────────────────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
private fun WelcomeStep(onNext: () -> Unit) {
|
private fun WelcomeStep(onNext: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text("🩺", style = MaterialTheme.typography.displayLarge)
|
// "VOLUME ONE · ENTRY 01" monogram header
|
||||||
|
Text(
|
||||||
|
text = "VOLUME ONE · ENTRY 01",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 3.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// H&S — large italic serif
|
||||||
Text(
|
Text(
|
||||||
text = "H&S Diary",
|
text = buildAnnotatedString {
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
withStyle(SpanStyle(color = FgColor)) { append("H") }
|
||||||
fontWeight = FontWeight.Bold
|
withStyle(SpanStyle(color = FgColor.copy(alpha = 0.45f))) { append("&") }
|
||||||
)
|
withStyle(SpanStyle(color = FgColor)) { append("S") }
|
||||||
Spacer(Modifier.height(12.dp))
|
},
|
||||||
Text(
|
fontFamily = CormorantGaramond,
|
||||||
text = "Your private health & cycle tracker.\nAll data stays on your device.",
|
fontStyle = FontStyle.Italic,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 44.sp,
|
||||||
|
lineHeight = 44.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
letterSpacing = (-0.5).sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Diary",
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 44.sp,
|
||||||
|
lineHeight = 45.sp,
|
||||||
|
color = FgColor,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
letterSpacing = (-0.5).sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
|
||||||
|
// Tagline
|
||||||
|
Text(
|
||||||
|
text = "A quiet ledger for your body —\nsymptoms, cycle, and the days between.",
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
color = FgMutedColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
// Ornament divider
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = BorderSoftColor,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "✦",
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = AccentColor
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = BorderSoftColor,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
// Feature list
|
||||||
|
val features = listOf(
|
||||||
|
"❍" to "Track period, fertility & ovulation",
|
||||||
|
"❍" to "Log symptoms, mood, intimacy",
|
||||||
|
"❍" to "Stays on your device. No cloud.",
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
features.forEach { (bullet, label) ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = bullet,
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = AccentColor,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
color = FgColor
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(48.dp))
|
|
||||||
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text("Get Started")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
private fun ProfileSetupStep(
|
private fun ProfileSetupStep(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -100,36 +250,84 @@ private fun ProfileSetupStep(
|
|||||||
isLoading: Boolean = false
|
isLoading: Boolean = false
|
||||||
) {
|
) {
|
||||||
Column(
|
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)
|
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) {
|
if (subtitle != null) {
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
subtitle,
|
||||||
|
fontFamily = InstrumentSans,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = FgMutedColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { if (it.length <= 32) onNameChange(it) },
|
onValueChange = { if (it.length <= 32) onNameChange(it) },
|
||||||
label = { Text("Name") },
|
label = { Text("Name", fontFamily = InstrumentSans, fontSize = 12.sp) },
|
||||||
singleLine = true,
|
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
|
// Profile type
|
||||||
Text("Profile type", style = MaterialTheme.typography.labelLarge)
|
Text(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
"PROFILE TYPE",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ProfileType.values().forEach { type ->
|
ProfileType.values().forEach { type ->
|
||||||
FilterChip(
|
val sel = profileType == type
|
||||||
selected = profileType == type,
|
Box(
|
||||||
onClick = { onTypeChange(type) },
|
modifier = Modifier
|
||||||
label = { Text(if (type == ProfileType.FEMALE) "Female" else "Male") }
|
.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
|
// Avatar color
|
||||||
Text("Avatar color", style = MaterialTheme.typography.labelLarge)
|
Text(
|
||||||
|
"AVATAR COLOUR",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
AvatarColors.forEachIndexed { idx, color ->
|
AvatarColors.forEachIndexed { idx, color ->
|
||||||
Box(
|
Box(
|
||||||
@@ -138,7 +336,8 @@ private fun ProfileSetupStep(
|
|||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color)
|
.background(color)
|
||||||
.then(
|
.then(
|
||||||
if (idx == colorIndex) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
if (idx == colorIndex)
|
||||||
|
Modifier.border(2.5.dp, FgColor, CircleShape)
|
||||||
else Modifier
|
else Modifier
|
||||||
)
|
)
|
||||||
.clickable { onColorChange(idx) }
|
.clickable { onColorChange(idx) }
|
||||||
@@ -148,17 +347,48 @@ private fun ProfileSetupStep(
|
|||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
Button(
|
Box(
|
||||||
onClick = onNext,
|
modifier = Modifier
|
||||||
enabled = !isLoading && (name.isNotBlank() || canSkip),
|
.fillMaxWidth()
|
||||||
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)
|
if (isLoading) {
|
||||||
else Text(if (canSkip && name.isBlank()) "Skip" else "Continue")
|
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()) {
|
if (canSkip && name.isNotBlank()) {
|
||||||
TextButton(onClick = { onSkip?.invoke() }, modifier = Modifier.fillMaxWidth()) {
|
Box(
|
||||||
Text("Skip for now")
|
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.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.hsdiary.data.db.entity.ProfileEntity
|
import com.hsdiary.data.db.entity.ProfileEntity
|
||||||
import com.hsdiary.ui.calendar.parseColor
|
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(
|
private val colorHexes = listOf(
|
||||||
"#E91E63","#9C27B0","#2196F3","#009688",
|
"#E38973", "#9C6BD0", "#5090D0", "#40A898",
|
||||||
"#4CAF50","#FF9800","#FF4081","#7C4DFF"
|
"#68A868", "#D09040", "#D07090", "#8A7AE8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -37,96 +40,90 @@ fun SettingsScreen(
|
|||||||
var clearProfileTarget by remember { mutableStateOf<Long?>(null) }
|
var clearProfileTarget by remember { mutableStateOf<Long?>(null) }
|
||||||
var showClearAll by remember { mutableStateOf(false) }
|
var showClearAll by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("Settings") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.background(BgColor)
|
||||||
|
) {
|
||||||
|
DiaryTopBar(showBack = true, onBack = onBack, title = "Settings")
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
// App settings
|
// ── I. Preferences ────────────────────────────────────────────────
|
||||||
SettingsSection("App") {
|
SectionLabel(title = "Preferences", num = "I.")
|
||||||
// First day of week
|
DiarySettingsCard(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
ListItem(
|
AubergineSettingRow(label = "First day of week") {
|
||||||
headlineContent = { Text("First day of week") },
|
AubergineTabs(
|
||||||
trailingContent = {
|
options = listOf("SUN" to 1, "MON" to 2),
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
selectedKey = state.firstDayOfWeek,
|
||||||
FilterChip(
|
onSelect = viewModel::setFirstDayOfWeek
|
||||||
selected = state.firstDayOfWeek == 1,
|
|
||||||
onClick = { viewModel.setFirstDayOfWeek(1) },
|
|
||||||
label = { Text("Sunday") }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = state.firstDayOfWeek == 2,
|
|
||||||
onClick = { viewModel.setFirstDayOfWeek(2) },
|
|
||||||
label = { Text("Monday") }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
)
|
AubergineSettingRow(label = "App theme") {
|
||||||
HorizontalDivider()
|
AubergineTabs(
|
||||||
// Theme
|
options = listOf("LIGHT" to "LIGHT", "DARK" to "DARK", "AUTO" to "SYSTEM"),
|
||||||
ListItem(
|
selectedKey = state.appTheme,
|
||||||
headlineContent = { Text("App theme") },
|
onSelect = viewModel::setAppTheme
|
||||||
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
|
// ── II. Profiles ──────────────────────────────────────────────────
|
||||||
|
SectionLabel(title = "Profiles", num = "II.")
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
state.profiles.forEach { profile ->
|
state.profiles.forEach { profile ->
|
||||||
SettingsSection("Profile: ${profile.name}") {
|
ProfileRow(
|
||||||
ProfileSettingsCard(
|
|
||||||
profile = profile,
|
profile = profile,
|
||||||
onNameChange = { viewModel.updateProfileName(profile.id, it) },
|
onNameChange = { viewModel.updateProfileName(profile.id, it) },
|
||||||
onColorChange = { viewModel.updateProfileColor(profile.id, it) }
|
onColorChange = { viewModel.updateProfileColor(profile.id, it) },
|
||||||
)
|
onClearData = { clearProfileTarget = profile.id }
|
||||||
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 }
|
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data management
|
// ── III. Data ─────────────────────────────────────────────────────
|
||||||
SettingsSection("Data") {
|
SectionLabel(title = "Data", num = "III.")
|
||||||
ListItem(
|
DiarySettingsCard(modifier = Modifier.padding(horizontal = 18.dp)) {
|
||||||
headlineContent = { Text("Clear all data", color = MaterialTheme.colorScheme.error) },
|
AubergineSettingRow(label = "Export as JSON", trailing = "›")
|
||||||
supportingContent = { Text("Removes all profiles and data — cannot be undone") },
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
modifier = Modifier.clickable { showClearAll = true }
|
AubergineSettingRow(
|
||||||
|
label = "Clear all data",
|
||||||
|
labelColor = Color(0xFFD06060),
|
||||||
|
trailing = "›",
|
||||||
|
onClick = { showClearAll = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
// ── Colophon ──────────────────────────────────────────────────────
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp, vertical = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"H&S Diary · All data stored locally on this device.",
|
text = "H&S Diary — kept privately,\non this device alone.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontFamily = CormorantGaramond,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
fontStyle = FontStyle.Italic,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 19.sp,
|
||||||
|
color = FgSubtleColor,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "v 2.6.0",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 2.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,97 +132,186 @@ fun SettingsScreen(
|
|||||||
val profile = state.profiles.find { it.id == profileId }
|
val profile = state.profiles.find { it.id == profileId }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { clearProfileTarget = null },
|
onDismissRequest = { clearProfileTarget = null },
|
||||||
title = { Text("Clear data?") },
|
containerColor = SurfaceColor,
|
||||||
text = { Text("This will remove all logs for ${profile?.name}. This cannot be undone.") },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(
|
||||||
viewModel.clearProfileData(profileId)
|
onClick = { viewModel.clearProfileData(profileId); clearProfileTarget = null },
|
||||||
clearProfileTarget = null
|
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFD06060))
|
||||||
}, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
|
) { Text("Clear", fontFamily = InstrumentSans) }
|
||||||
Text("Clear")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
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) {
|
if (showClearAll) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showClearAll = false },
|
onDismissRequest = { showClearAll = false },
|
||||||
title = { Text("Clear all data?") },
|
containerColor = SurfaceColor,
|
||||||
text = { Text("This will remove all profiles, logs, and reset the app. This CANNOT be undone.") },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { viewModel.clearAllData(); showClearAll = false },
|
onClick = { viewModel.clearAllData(); showClearAll = false },
|
||||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFD06060))
|
||||||
) { Text("Clear Everything") }
|
) { Text("Clear Everything", fontFamily = InstrumentSans) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showClearAll = false }) { Text("Cancel") }
|
TextButton(
|
||||||
|
onClick = { showClearAll = false },
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = FgMutedColor)
|
||||||
|
) { Text("Cancel", fontFamily = InstrumentSans) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Profile row card ─────────────────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
private fun ProfileRow(
|
||||||
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(
|
|
||||||
profile: ProfileEntity,
|
profile: ProfileEntity,
|
||||||
onNameChange: (String) -> Unit,
|
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 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)) {
|
Box(
|
||||||
// Name
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(SurfaceColor, RoundedCornerShape(14.dp))
|
||||||
|
.border(1.dp, BorderSoftColor, RoundedCornerShape(14.dp))
|
||||||
|
.padding(14.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
AvatarDot(
|
||||||
|
name = profile.name,
|
||||||
|
avatarColor = parseColor(profile.avatarColor),
|
||||||
|
size = 32
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = profile.name,
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = FgColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = profile.profileType.uppercase(),
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = FgFaintColor,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (expanded) "−" else "›",
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = FgFaintColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Name edit
|
||||||
if (nameEditMode) {
|
if (nameEditMode) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = editingName,
|
value = editingName,
|
||||||
onValueChange = { if (it.length <= 32) editingName = it },
|
onValueChange = { if (it.length <= 32) editingName = it },
|
||||||
label = { Text("Name") },
|
label = { Text("Name", fontFamily = InstrumentSans, fontSize = 12.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = AccentColor,
|
||||||
|
unfocusedBorderColor = BorderColor,
|
||||||
|
focusedTextColor = FgColor,
|
||||||
|
unfocusedTextColor = FgColor,
|
||||||
|
focusedLabelColor = AccentDimColor,
|
||||||
|
unfocusedLabelColor = FgSubtleColor
|
||||||
|
),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
onNameChange(editingName)
|
onNameChange(editingName)
|
||||||
nameEditMode = false
|
nameEditMode = false
|
||||||
}) { Text("Save") }
|
}) {
|
||||||
|
Text("Save", fontFamily = InstrumentSans, color = AccentColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ListItem(
|
Row(
|
||||||
headlineContent = { Text(profile.name) },
|
modifier = Modifier
|
||||||
supportingContent = { Text("Tap to edit name") },
|
.fillMaxWidth()
|
||||||
modifier = Modifier.clickable { nameEditMode = true }
|
.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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Avatar color", style = MaterialTheme.typography.labelMedium)
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Spacer(Modifier.height(8.dp))
|
colorHexes.forEach { hex ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 4.dp)) {
|
|
||||||
colorHexes.forEachIndexed { idx, hex ->
|
|
||||||
val color = parseColor(hex)
|
val color = parseColor(hex)
|
||||||
val isSelected = profile.avatarColor == hex
|
val isSelected = profile.avatarColor == hex
|
||||||
Box(
|
Box(
|
||||||
@@ -233,10 +319,114 @@ private fun ProfileSettingsCard(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color)
|
.background(color)
|
||||||
.then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
|
.then(
|
||||||
|
if (isSelected)
|
||||||
|
Modifier.border(2.dp, FgColor, CircleShape)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
.clickable { onColorChange(hex) }
|
.clickable { onColorChange(hex) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Clear profile data",
|
||||||
|
fontFamily = InstrumentSans,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color(0xFFD06060),
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onClearData)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper composables ───────────────────────────────────────────────────────
|
||||||
|
@Composable
|
||||||
|
private fun DiarySettingsCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(SurfaceColor, RoundedCornerShape(14.dp))
|
||||||
|
.border(1.dp, BorderSoftColor, RoundedCornerShape(14.dp))
|
||||||
|
) {
|
||||||
|
Column(content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AubergineSettingRow(
|
||||||
|
label: String,
|
||||||
|
labelColor: Color = FgColor,
|
||||||
|
hint: String? = null,
|
||||||
|
trailing: String? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
control: (@Composable () -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(label, fontFamily = InstrumentSans, fontSize = 13.sp, color = labelColor)
|
||||||
|
if (hint != null) {
|
||||||
|
Text(hint, fontFamily = InstrumentSans, fontSize = 11.sp, color = FgSubtleColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
control?.invoke()
|
||||||
|
if (trailing != null) {
|
||||||
|
Text(
|
||||||
|
text = trailing,
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = FgFaintColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> AubergineTabs(
|
||||||
|
options: List<Pair<String, T>>,
|
||||||
|
selectedKey: T,
|
||||||
|
onSelect: (T) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(SurfaceLoColor, RoundedCornerShape(999.dp))
|
||||||
|
.border(1.dp, BorderSoftColor, RoundedCornerShape(999.dp))
|
||||||
|
.padding(3.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
options.forEach { (label, key) ->
|
||||||
|
val sel = key == selectedKey
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
if (sel) SurfaceHiColor else Color.Transparent,
|
||||||
|
RoundedCornerShape(999.dp)
|
||||||
|
)
|
||||||
|
.clickable { onSelect(key) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontFamily = JetBrainsMono,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
letterSpacing = 0.2.sp,
|
||||||
|
color = if (sel) FgColor else FgSubtleColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,52 @@ package com.hsdiary.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
// Material theme seeds
|
// ─── Aubergine Nocturne — base surfaces ─────────────────────────────────────
|
||||||
val PrimaryPink = Color(0xFFE91E63)
|
// All values converted from the design's OKLCH tokens to sRGB.
|
||||||
val PrimaryPinkDark = Color(0xFFC2185B)
|
val BgColor = Color(0xFF1C1015) // oklch(0.16 0.012 340)
|
||||||
val OnPrimary = Color(0xFFFFFFFF)
|
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
|
// ─── Text — warm cream scale ─────────────────────────────────────────────────
|
||||||
val PeriodColor = Color(0xFFB71C1C)
|
val FgColor = Color(0xFFF1ECE4) // oklch(0.945 0.012 80)
|
||||||
val PeriodPredictedColor = Color(0xFFEF9A9A)
|
val FgMutedColor = Color(0xFFA49890) // oklch(0.72 0.018 60)
|
||||||
val FertileColor = Color(0xFF00796B)
|
val FgSubtleColor = Color(0xFF7A6A5E) // oklch(0.55 0.018 50)
|
||||||
val FertilePredictedColor = Color(0xFF80CBC4)
|
val FgFaintColor = Color(0xFF594842) // oklch(0.40 0.015 50)
|
||||||
val OvulationColor = Color(0xFF00897B)
|
|
||||||
val LutealColor = Color(0xFFF57F17)
|
|
||||||
val FollicularColor = Color(0xFFE0E0E0)
|
|
||||||
|
|
||||||
// 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(
|
val AvatarColors = listOf(
|
||||||
Color(0xFFE91E63), // Rose
|
AccentColor, // Copper Rose
|
||||||
Color(0xFF9C27B0), // Purple
|
Color(0xFF9C6BD0), // Purple
|
||||||
Color(0xFF2196F3), // Blue
|
Color(0xFF5090D0), // Blue
|
||||||
Color(0xFF009688), // Teal
|
Color(0xFF40A898), // Teal
|
||||||
Color(0xFF4CAF50), // Green
|
Color(0xFF68A868), // Green
|
||||||
Color(0xFFFF9800), // Orange
|
Color(0xFFD09040), // Orange
|
||||||
Color(0xFFFF4081), // Pink accent
|
Color(0xFFD07090), // Pink
|
||||||
Color(0xFF7C4DFF) // Violet
|
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
|
package com.hsdiary.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val LightColors = lightColorScheme(
|
// ─── Aubergine Nocturne — fixed dark colour scheme ───────────────────────────
|
||||||
primary = PrimaryPink,
|
// We never use dynamic colour; the Aubergine Nocturne palette is the identity
|
||||||
onPrimary = OnPrimary,
|
// of the app, and light/dynamic modes would destroy it.
|
||||||
primaryContainer = Color(0xFFFCE4EC),
|
private val AubergineNocturne = darkColorScheme(
|
||||||
onPrimaryContainer = Color(0xFF880E4F),
|
// Primary — Copper Rose accent
|
||||||
secondary = Color(0xFF009688),
|
primary = AccentColor,
|
||||||
onSecondary = OnPrimary,
|
onPrimary = AccentOnColor,
|
||||||
secondaryContainer = Color(0xFFE0F2F1),
|
primaryContainer = AccentFaintColor,
|
||||||
onSecondaryContainer = Color(0xFF004D40),
|
onPrimaryContainer = FgColor,
|
||||||
surface = Color(0xFFFFFBFE),
|
// Secondary — warm gold (luteal phase)
|
||||||
onSurface = Color(0xFF1C1B1F),
|
secondary = LutealColor,
|
||||||
surfaceVariant = Color(0xFFF5F5F5),
|
onSecondary = AccentOnColor,
|
||||||
onSurfaceVariant = Color(0xFF49454F),
|
secondaryContainer = Color(0xFF3A2800),
|
||||||
outline = Color(0xFF79747E)
|
onSecondaryContainer = FgColor,
|
||||||
)
|
// Tertiary — teal (ovulation / fertile)
|
||||||
|
tertiary = OvulationColor,
|
||||||
private val DarkColors = darkColorScheme(
|
onTertiary = Color(0xFF00301C),
|
||||||
primary = Color(0xFFF48FB1),
|
tertiaryContainer = Color(0xFF003828),
|
||||||
onPrimary = Color(0xFF880E4F),
|
onTertiaryContainer = FgColor,
|
||||||
primaryContainer = Color(0xFFAD1457),
|
// Error
|
||||||
onPrimaryContainer = Color(0xFFFCE4EC),
|
error = PeriodColor,
|
||||||
secondary = Color(0xFF80CBC4),
|
onError = FgColor,
|
||||||
onSecondary = Color(0xFF004D40),
|
errorContainer = Color(0xFF3A1010),
|
||||||
surface = Color(0xFF1C1B1F),
|
onErrorContainer = FgColor,
|
||||||
onSurface = Color(0xFFE6E1E5)
|
// 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
|
@Composable
|
||||||
fun HSDiaryTheme(
|
fun HSDiaryTheme(content: @Composable () -> Unit) {
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
CompositionLocalProvider(
|
||||||
content: @Composable () -> Unit
|
LocalDiaryTypography provides DiaryTypography()
|
||||||
) {
|
) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = AubergineNocturne,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,101 @@
|
|||||||
package com.hsdiary.ui.theme
|
package com.hsdiary.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.text.TextStyle
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.googlefonts.Font
|
||||||
|
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.hsdiary.R
|
||||||
|
|
||||||
val Typography = Typography(
|
private val provider = GoogleFont.Provider(
|
||||||
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp),
|
providerAuthority = "com.google.android.gms.fonts",
|
||||||
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp),
|
providerPackage = "com.google.android.gms",
|
||||||
bodySmall = TextStyle(fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp),
|
certificates = R.array.com_google_android_gms_fonts_certs
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ─── 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
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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
|
@Composable
|
||||||
fun HealthTrendsScreen(
|
fun HealthTrendsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -25,83 +27,109 @@ fun HealthTrendsScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Column(
|
||||||
topBar = {
|
modifier = Modifier
|
||||||
TopAppBar(
|
.fillMaxSize()
|
||||||
title = { Text("Health Trends") },
|
.background(BgColor)
|
||||||
navigationIcon = {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
DiaryTopBar(
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
showBack = true,
|
||||||
}
|
onBack = onBack,
|
||||||
|
title = "Trends",
|
||||||
|
subtitle = when (state.range) {
|
||||||
|
TrendsRange.DAYS_30 -> "last 30 days"
|
||||||
|
TrendsRange.MONTHS_3 -> "last 90 days"
|
||||||
|
TrendsRange.MONTHS_6 -> "last 6 months"
|
||||||
|
TrendsRange.ALL -> "all time"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator(color = AccentColor)
|
||||||
}
|
}
|
||||||
return@Scaffold
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(bottom = 32.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
// Range selector
|
// Range switcher
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
TrendsRange.entries.forEach { range ->
|
TrendsRange.entries.forEach { range ->
|
||||||
FilterChip(
|
val selected = state.range == range
|
||||||
selected = state.range == range,
|
val label = when (range) {
|
||||||
onClick = { viewModel.setRange(range) },
|
TrendsRange.DAYS_30 -> "30D"
|
||||||
label = {
|
TrendsRange.MONTHS_3 -> "3M"
|
||||||
Text(when (range) {
|
TrendsRange.MONTHS_6 -> "6M"
|
||||||
TrendsRange.DAYS_30 -> "30d"
|
TrendsRange.ALL -> "ALL"
|
||||||
TrendsRange.MONTHS_3 -> "3m"
|
|
||||||
TrendsRange.MONTHS_6 -> "6m"
|
|
||||||
TrendsRange.ALL -> "All"
|
|
||||||
}, style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
}
|
||||||
|
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()) {
|
if (state.conditionFrequencies.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxWidth().padding(vertical = 48.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp, horizontal = 18.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"No conditions logged in this range.",
|
"No conditions logged in this range.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
fontFamily = CormorantGaramond,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = FgMutedColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
Text(
|
SectionLabel(title = "Most logged", num = "I.")
|
||||||
"Logged this period",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
items(state.conditionFrequencies) { freq ->
|
items(
|
||||||
ConditionFrequencyCard(
|
items = state.conditionFrequencies,
|
||||||
frequency = freq,
|
key = { it.definition.conditionKey }
|
||||||
isSelected = state.selectedCondition?.conditionKey == freq.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 = {
|
onClick = {
|
||||||
viewModel.selectCondition(
|
viewModel.selectCondition(
|
||||||
if (state.selectedCondition?.conditionKey == freq.definition.conditionKey) null
|
if (isSelected) null else freq.definition
|
||||||
else freq.definition
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -112,61 +140,127 @@ fun HealthTrendsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConditionFrequencyCard(
|
private fun TrendsConditionCard(
|
||||||
frequency: ConditionFrequency,
|
freq: ConditionFrequency,
|
||||||
|
accent: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val barColor = MaterialTheme.colorScheme.primary
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
ElevatedCard(
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
|
.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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
// Name + recurring badge
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
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(
|
||||||
"${frequency.count} times · avg rating ${"%.1f".format(frequency.avgRating)}/5",
|
text = freq.definition.displayName,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontFamily = CormorantGaramond,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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(
|
Text(
|
||||||
"×${frequency.count}",
|
text = "${freq.count}× · AVG ${"%.1f".format(freq.avgRating)}/5",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
fontFamily = JetBrainsMono,
|
||||||
fontWeight = FontWeight.Bold,
|
fontSize = 10.sp,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = FgSubtleColor,
|
||||||
|
letterSpacing = 0.6.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected && frequency.weeklyData.isNotEmpty()) {
|
// Sparkline bars
|
||||||
|
if (freq.weeklyData.isNotEmpty()) {
|
||||||
|
val maxW = (freq.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.height(26.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
freq.weeklyData.forEach { w ->
|
||||||
|
val barFraction = (w / maxW).coerceAtLeast(0.08f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(6.dp)
|
||||||
|
.fillMaxHeight(barFraction)
|
||||||
|
.background(
|
||||||
|
if (accent) AccentColor else SurfaceHiColor,
|
||||||
|
RoundedCornerShape(1.dp)
|
||||||
|
)
|
||||||
|
.border(0.5.dp, BorderSoftColor, RoundedCornerShape(1.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Big count number
|
||||||
|
Text(
|
||||||
|
text = freq.count.toString(),
|
||||||
|
fontFamily = CormorantGaramond,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
color = if (accent) AccentColor else FgMutedColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded weekly chart
|
||||||
|
if (isSelected && freq.weeklyData.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text("Weekly occurrences", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
val maxCount = (frequency.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f)
|
Text(
|
||||||
Canvas(modifier = Modifier.fillMaxWidth().height(60.dp)) {
|
"WEEKLY OCCURRENCES",
|
||||||
val barW = size.width / (frequency.weeklyData.size * 1.5f)
|
fontFamily = JetBrainsMono,
|
||||||
val gap = barW * 0.5f
|
fontSize = 9.sp,
|
||||||
frequency.weeklyData.forEachIndexed { i, count ->
|
color = FgSubtleColor,
|
||||||
val h = (count / maxCount) * size.height * 0.9f
|
letterSpacing = 0.5.sp
|
||||||
drawRect(
|
)
|
||||||
color = barColor,
|
Spacer(Modifier.height(8.dp))
|
||||||
topLeft = Offset(i * (barW + gap), size.height - h),
|
val maxCount = (freq.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f)
|
||||||
size = Size(barW, h)
|
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))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling
|
|||||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
|
||||||
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
|||||||
Reference in New Issue
Block a user