Compare commits

..

3 Commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:44:57 -05:00
cd276ed44c Refine period tracking logic and UI feedback in DayDetailScreen.
- Update `DayDetailUiState` to include `hasActiveCycle` and `cycleStartDate` to better track ongoing cycles.
- Replace `periodActive` check with `hasActiveCycle` for displaying the "Period day" badge and toggle icons.
- Improve validation for the "Period ended" action:
    - Enable the chip only if a cycle is active and the current date is after the cycle's start date.
- Update `DayDetailViewModel` to accurately calculate `periodDayNumber` and cycle status by fetching records from the repository.
- Ensure period state is correctly reset or initialized when toggling period start/end.

Signed-off-by: whitlocktech <whitlocktech@gmail.com>
2026-05-22 17:52:18 -05:00
17 changed files with 2733 additions and 1146 deletions

View File

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

View File

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

View File

@@ -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,85 +83,153 @@ 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, nextPeriodDate = null,
nextPeriodDate = null, fertileWindowStart = null,
fertileWindowStart = null, fertileWindowEnd = null,
fertileWindowEnd = null, ovulationDate = null,
ovulationDate = null, averageCycleLength = defaultLength,
averageCycleLength = defaultLength, cyclesLogged = 0,
cyclesLogged = 0, tier = 1,
tier = 1, 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
}
} }

View File

@@ -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, actions = {
style = MaterialTheme.typography.labelSmall, IconButton(
color = MaterialTheme.colorScheme.onSurfaceVariant onClick = onTrendsClick,
) modifier = Modifier.size(32.dp)
} ) {
} Icon(
}, Icons.AutoMirrored.Filled.TrendingUp,
actions = { contentDescription = "Trends",
IconButton(onClick = onTrendsClick) { tint = FgMutedColor,
Icon(Icons.AutoMirrored.Filled.TrendingUp, contentDescription = "Trends") modifier = Modifier.size(18.dp)
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
if (activeProfile != null) {
val avatarColor = parseColor(activeProfile.avatarColor)
IconButton(onClick = { viewModel.showProfileSheet() }) {
AvatarDot(name = activeProfile.name, avatarColor = avatarColor, size = 32)
}
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Context banner (female only)
if (activeProfile?.profileType == ProfileType.FEMALE.name) {
val prediction = state.prediction
ContextBanner(
prediction = prediction,
onClick = onInsightsClick
)
}
// Month navigation
MonthHeader(
month = state.currentMonth,
onPrevious = viewModel::previousMonth,
onNext = viewModel::nextMonth
)
// Day-of-week headers
val dayHeaders = if (state.firstDayOfWeek == 1)
listOf("S","M","T","W","T","F","S")
else
listOf("M","T","W","T","F","S","S")
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
dayHeaders.forEach { h ->
Text(
text = h,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
IconButton(
onClick = onSettingsClick,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Settings,
contentDescription = "Settings",
tint = FgMutedColor,
modifier = Modifier.size(18.dp)
)
}
if (activeProfile != null) {
Box(
modifier = Modifier
.size(30.dp)
.background(parseColor(activeProfile.avatarColor), CircleShape)
.clickable { viewModel.showProfileSheet() },
contentAlignment = Alignment.Center
) {
Text(
text = activeProfile.name.take(1).uppercase(),
fontFamily = InstrumentSans,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
color = BgColor
)
}
}
} }
)
// Calendar grid // ── Cycle status banner (female profiles only) ───────────────────────
val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name if (isFemale) {
CalendarGrid( CycleStatusBanner(
days = state.dayStates, prediction = state.prediction,
onClick = onInsightsClick
)
}
// ── Month navigation ─────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 14.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = viewModel::previousMonth) {
Text("", fontFamily = CormorantGaramond, fontSize = 22.sp, color = FgMutedColor)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = state.currentMonth.month.getDisplayName(JTextStyle.FULL, Locale.getDefault()),
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 23.sp,
color = FgColor
)
Text(
text = state.currentMonth.year.toString(),
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgFaintColor,
letterSpacing = 2.sp
)
}
IconButton(onClick = viewModel::nextMonth) {
Text("", fontFamily = CormorantGaramond, fontSize = 22.sp, color = FgMutedColor)
}
}
// ── Day-of-week headers ──────────────────────────────────────────────
val dowLabels = if (state.firstDayOfWeek == 1)
listOf("S", "M", "T", "W", "T", "F", "S")
else
listOf("M", "T", "W", "T", "F", "S", "S")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(bottom = 4.dp)
) {
dowLabels.forEach { label ->
Text(
text = label,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgFaintColor,
letterSpacing = 1.sp
)
}
}
// ── Calendar grid ────────────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 4.dp)
) {
state.dayStates.chunked(7).forEach { week ->
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
week.forEach { day ->
AubergineDay(
day = day,
isFemale = isFemale,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
onClick = { onDayClick(day.date.toString()) }
)
}
}
}
}
// ── Today footer ─────────────────────────────────────────────────────
val today = state.dayStates.firstOrNull { it.isToday }
if (today != null) {
TodayFooter(
today = today,
isFemale = isFemale, isFemale = isFemale,
onDayClick = { onDayClick(it.toString()) } 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), modifier = Modifier
verticalAlignment = Alignment.CenterVertically .fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
IconButton(onClick = onPrevious) { GlowDot(color = dotColor)
Icon(Icons.Default.ChevronLeft, contentDescription = "Previous month") Column(modifier = Modifier.weight(1f)) {
} Text(
Text( text = phaseText,
text = "${month.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${month.year}", fontFamily = CormorantGaramond,
modifier = Modifier.weight(1f), fontStyle = FontStyle.Italic,
textAlign = TextAlign.Center, fontSize = 16.sp,
style = MaterialTheme.typography.titleMedium, lineHeight = 19.sp,
fontWeight = FontWeight.SemiBold color = FgColor
) )
IconButton(onClick = onNext) { Text(
Icon(Icons.Default.ChevronRight, contentDescription = "Next month") text = subtitle.uppercase(),
fontFamily = JetBrainsMono,
fontSize = 10.5.sp,
color = FgSubtleColor,
letterSpacing = 0.63.sp,
modifier = Modifier.padding(top = 3.dp)
)
} }
Text(text = "", fontFamily = CormorantGaramond, fontSize = 18.sp, color = FgFaintColor)
} }
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
} }
// ─── Day cell ────────────────────────────────────────────────────────────────
@Composable @Composable
private fun CalendarGrid( private fun AubergineDay(
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, day: DayState,
isFemale: Boolean, isFemale: Boolean,
onClick: () -> Unit, modifier: Modifier = Modifier,
modifier: Modifier = Modifier onClick: () -> Unit
) { ) {
val phaseColor = if (isFemale) phaseColor(day.phase) else null val phase = if (isFemale) phaseColorForDot(day.phase) else null
val textAlpha = if (day.isCurrentMonth) 1f else 0.35f val isPeriodConfirmed = day.phase == CyclePhase.MENSTRUATION_CONFIRMED
val isPredicted = isFemale && isPredicted(day.phase)
val icons = buildIconList(day, isFemale)
Box( // Ring style visual
val bubbleBg: Color = if (isPeriodConfirmed) (phase ?: Color.Transparent) else Color.Transparent
val bubbleBorderColor: Color = when {
day.isToday && !isPeriodConfirmed -> AccentColor
phase != null && !isPeriodConfirmed ->
if (isPredicted) phase.copy(alpha = 0.55f) else phase
isPeriodConfirmed -> phase ?: Color.Transparent
else -> Color.Transparent
}
val bubbleBorderWidth = if (day.isToday) 1.5.dp else 1.dp
Column(
modifier = modifier modifier = modifier
.aspectRatio(1f)
.padding(1.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.alpha(if (day.isCurrentMonth) 1f else 0.35f)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Phase color band at bottom // Number bubble
if (phaseColor != null) { Box(
Box( modifier = Modifier
modifier = Modifier .size(30.dp)
.fillMaxWidth() .background(bubbleBg, CircleShape)
.fillMaxHeight(0.25f) .then(
.align(Alignment.BottomCenter) if (bubbleBorderColor != Color.Transparent)
.background( Modifier.border(bubbleBorderWidth, bubbleBorderColor, CircleShape)
phaseColor.copy( else Modifier
alpha = if (isPredicted(day.phase)) 0.45f else 0.75f ),
) contentAlignment = Alignment.Center
)
)
}
// Today ring
if (day.isToday) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.TopCenter)
.offset(y = 3.dp)
)
}
Column(
modifier = Modifier.fillMaxSize().padding(2.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Day number
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 (day.hasIntimacy) {
} Text("", fontSize = 8.5.sp, lineHeight = 10.sp, color = AccentColor)
if (overflow > 0) Text("+$overflow", fontSize = 7.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (day.hasIntimacy) Text("❤️", fontSize = 9.sp, lineHeight = 10.sp)
} }
} }
} }
} }
} }
// ─── 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
}

View File

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

View File

@@ -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(
Text( name = profile.name,
text = profile.name, avatarColor = profile.avatarColor,
style = MaterialTheme.typography.bodyLarge, size = 36
modifier = Modifier.weight(1f)
) )
Column(modifier = Modifier.weight(1f)) {
Text(
text = profile.name,
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 17.sp,
color = FgColor
)
Text(
text = if (profile.isActive) "CURRENTLY OPEN" else "TAP TO SWITCH",
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgFaintColor,
letterSpacing = 1.sp,
modifier = Modifier.padding(top = 2.dp)
)
}
if (profile.isActive) { if (profile.isActive) {
Badge { Text("Active") } Box(
modifier = Modifier
.background(Color.Transparent, RoundedCornerShape(999.dp))
.border(1.dp, AccentFaintColor, RoundedCornerShape(999.dp))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
"ACTIVE",
fontFamily = JetBrainsMono,
fontSize = 9.sp,
color = AccentColor,
letterSpacing = 1.2.sp
)
}
} }
} }
} }
Spacer(Modifier.height(14.dp))
// Add profile button — outline style
Box(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderColor, RoundedCornerShape(999.dp))
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "+ Add a profile",
fontFamily = InstrumentSans,
fontSize = 13.sp,
color = FgColor
)
}
} }
} }
// Confirm switch dialog
confirmTarget?.let { target -> 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(
onProfileSelected(target.id) onClick = {
confirmTarget = null onProfileSelected(target.id)
onDismiss() confirmTarget = null
}) { Text("Switch") } onDismiss()
},
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)
}
} }
) )
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,11 +1,11 @@
package com.hsdiary.ui.insights 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"
) { padding -> else null
)
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" // Phase name — large serif italic
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "🌿 Fertile Window" val phaseName = when (prediction.currentPhase) {
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "🌿 Ovulation Day" CyclePhase.MENSTRUATION_CONFIRMED,
CyclePhase.LUTEAL -> "🌙 Luteal Phase" CyclePhase.MENSTRUATION_PREDICTED -> "Menstruation."
CyclePhase.FOLLICULAR -> "🌱 Follicular Phase" CyclePhase.FERTILE_WINDOW,
else -> "" CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile window."
CyclePhase.OVULATION,
CyclePhase.OVULATION_PREDICTED -> "Ovulation."
CyclePhase.LUTEAL -> "Luteal phase."
CyclePhase.FOLLICULAR -> "Follicular phase."
else -> "Tracking."
} }
Text(phaseText, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text(
if (prediction.currentCycleDay > 0) { text = phaseName,
Text("Day ${prediction.currentCycleDay}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 30.sp,
lineHeight = 32.sp,
color = FgColor
)
// Subtitle
val phaseSubtitle = when (prediction.currentPhase) {
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED ->
prediction.nextPeriodDate?.let {
val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault())
"A single peak day. Fertile window narrowing — next period predicted for ${it.format(fmt)}."
} ?: "A single peak day."
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED ->
"Elevated fertility. Track closely."
CyclePhase.MENSTRUATION_CONFIRMED ->
"Period day ${prediction.currentCycleDay}."
else -> prediction.daysUntilNextPeriod?.let { "Next period in ~$it days." } ?: ""
} }
if (phaseSubtitle.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
text = phaseSubtitle,
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 14.sp,
lineHeight = 20.sp,
color = FgMutedColor
)
}
// Phase arc
Spacer(Modifier.height(18.dp))
PhaseArc(
cycleDay = prediction.currentCycleDay,
avgLength = prediction.averageCycleLength
)
} }
} }
// Stats row // ── II. By the numbers ─────────────────────────────────────────────
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { SectionLabel(title = "By the numbers", num = "II.")
StatCard( Row(
label = "Avg Cycle", modifier = Modifier
value = "${prediction.averageCycleLength} days", .fillMaxWidth()
modifier = Modifier.weight(1f) .padding(horizontal = 18.dp),
) horizontalArrangement = Arrangement.spacedBy(8.dp)
StatCard( ) {
label = "Cycles Logged", StatTile("Avg cycle", prediction.averageCycleLength.toString(), "days", Modifier.weight(1f))
value = "${prediction.cyclesLogged}", StatTile("Logged", prediction.cyclesLogged.toString(), "cycles", Modifier.weight(1f))
modifier = Modifier.weight(1f) StatTile("Tier", prediction.tier.toString(), "prediction", Modifier.weight(1f))
)
StatCard(
label = "Prediction",
value = "Tier ${prediction.tier}",
modifier = Modifier.weight(1f)
)
} }
// Next period // ── III. Upcoming ──────────────────────────────────────────────────
prediction.nextPeriodDate?.let { next -> SectionLabel(title = "Upcoming", num = "III.")
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(horizontal = 18.dp)) {
Column(modifier = Modifier.padding(16.dp)) { DiaryCard {
Text("Upcoming", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) prediction.nextPeriodDate?.let { nextDate ->
Spacer(Modifier.height(8.dp)) val fmt = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault()) Row(
Text("Next period: ${next.format(fmt)}", style = MaterialTheme.typography.bodyLarge) modifier = Modifier.fillMaxWidth(),
prediction.daysUntilNextPeriod?.let { days -> horizontalArrangement = Arrangement.SpaceBetween,
Text("In ~$days days", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) verticalAlignment = Alignment.Bottom
) {
Column {
Text(
"Next period",
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 16.sp,
color = FgColor
)
prediction.daysUntilNextPeriod?.let {
Spacer(Modifier.height(4.dp))
Text(
"IN ~$it DAYS",
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgSubtleColor,
letterSpacing = 1.sp
)
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
nextDate.format(fmt),
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 18.sp,
color = PeriodColor
)
Text(
"± 2 DAYS",
fontFamily = JetBrainsMono,
fontSize = 9.sp,
color = FgFaintColor,
letterSpacing = 0.6.sp
)
}
} }
prediction.fertileWindowStart?.let { fw -> }
val fwEnd = prediction.fertileWindowEnd
prediction.fertileWindowStart?.let { fw ->
val fwEnd = prediction.fertileWindowEnd
val fmt = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = BorderSoftColor,
thickness = 0.5.dp
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text( 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
) {
Text(
"LAST ${lengths.size} CYCLES",
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgSubtleColor,
letterSpacing = 0.5.sp
)
Text(
"AVG · ${lengths.average().toInt()} D",
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgSubtleColor,
letterSpacing = 0.5.sp
)
}
} }
} }
} }
Spacer(Modifier.height(32.dp))
} }
} }
} }
// ─── Phase arc ────────────────────────────────────────────────────────────────
@Composable @Composable
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { private fun PhaseArc(cycleDay: Int, avgLength: Int) {
ElevatedCard(modifier = modifier) { val total = avgLength.toFloat().coerceAtLeast(1f)
Column( val periodColor = PeriodColor
modifier = Modifier.padding(12.dp), val lutealColor = LutealColor
horizontalAlignment = Alignment.CenterHorizontally val fertileColor = FertileColor
val predColor = PeriodPredictedColor
val ovulColor = OvulationColor
val bgColor = BgColor
data class Seg(val from: Float, val to: Float, val color: Color, val alpha: Float = 1f)
val segs = listOf(
Seg(0f, 5f / total, periodColor),
Seg(5f / total, 10f / total, lutealColor),
Seg(10f / total, 15f / total, fertileColor),
Seg(15f / total, 23f / total, lutealColor),
Seg(23f / total, 1f, predColor, 0.5f),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier.size(160.dp, 140.dp),
contentAlignment = Alignment.Center
) { ) {
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Canvas(modifier = Modifier.size(160.dp, 140.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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))
Text( Row(modifier = Modifier.fillMaxWidth()) {
"Last ${cycles.size} cycles · Average: ${avg.toInt()} days", lengths.forEachIndexed { i, length ->
style = MaterialTheme.typography.bodySmall, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = length.toString(),
) modifier = Modifier.weight(1f),
fontFamily = JetBrainsMono,
fontSize = 9.sp,
color = if (i == lengths.lastIndex) AccentColor else FgFaintColor,
letterSpacing = 0.4.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
} }

View File

@@ -12,12 +12,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.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,64 +32,208 @@ fun OnboardingScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
when (state.step) { Box(
0 -> WelcomeStep(onNext = { viewModel.nextStep() }) modifier = Modifier
1 -> ProfileSetupStep( .fillMaxSize()
title = "Create your profile", .background(BgColor)
name = state.profile1Name,
colorIndex = state.profile1ColorIndex,
profileType = state.profile1Type,
onNameChange = viewModel::updateProfile1Name,
onColorChange = viewModel::updateProfile1Color,
onTypeChange = viewModel::updateProfile1Type,
onNext = { viewModel.nextStep() },
canSkip = false
)
2 -> ProfileSetupStep(
title = "Add a second profile",
subtitle = "Optional — can be added later in Settings",
name = state.profile2Name,
colorIndex = state.profile2ColorIndex,
profileType = state.profile2Type,
onNameChange = viewModel::updateProfile2Name,
onColorChange = viewModel::updateProfile2Color,
onTypeChange = viewModel::updateProfile2Type,
onNext = { viewModel.completeOnboarding(onComplete) },
canSkip = true,
onSkip = { viewModel.completeOnboarding(onComplete) },
isLoading = state.isLoading
)
}
}
@Composable
private fun WelcomeStep(onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text("🩺", style = MaterialTheme.typography.displayLarge) when (state.step) {
Spacer(Modifier.height(24.dp)) 0 -> WelcomeStep(onNext = { viewModel.nextStep() })
Text( 1 -> ProfileSetupStep(
text = "H&S Diary", title = "Create your profile",
style = MaterialTheme.typography.headlineLarge, name = state.profile1Name,
fontWeight = FontWeight.Bold colorIndex = state.profile1ColorIndex,
) profileType = state.profile1Type,
Spacer(Modifier.height(12.dp)) onNameChange = viewModel::updateProfile1Name,
Text( onColorChange = viewModel::updateProfile1Color,
text = "Your private health & cycle tracker.\nAll data stays on your device.", onTypeChange = viewModel::updateProfile1Type,
style = MaterialTheme.typography.bodyLarge, onNext = { viewModel.nextStep() },
textAlign = TextAlign.Center, canSkip = false
color = MaterialTheme.colorScheme.onSurfaceVariant )
) 2 -> ProfileSetupStep(
Spacer(Modifier.height(48.dp)) title = "Add a second profile",
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { subtitle = "Optional — can be added later in Settings",
Text("Get Started") name = state.profile2Name,
colorIndex = state.profile2ColorIndex,
profileType = state.profile2Type,
onNameChange = viewModel::updateProfile2Name,
onColorChange = viewModel::updateProfile2Color,
onTypeChange = viewModel::updateProfile2Type,
onNext = { viewModel.completeOnboarding(onComplete) },
canSkip = true,
onSkip = { viewModel.completeOnboarding(onComplete) },
isLoading = state.isLoading
)
} }
} }
} }
// ─── Welcome step ─────────────────────────────────────────────────────────────
@Composable
private fun WelcomeStep(onNext: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// "VOLUME ONE · ENTRY 01" monogram header
Text(
text = "VOLUME ONE · ENTRY 01",
fontFamily = JetBrainsMono,
fontSize = 10.sp,
color = FgFaintColor,
letterSpacing = 3.sp,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
// H&S — large italic serif
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = FgColor)) { append("H") }
withStyle(SpanStyle(color = FgColor.copy(alpha = 0.45f))) { append("&") }
withStyle(SpanStyle(color = FgColor)) { append("S") }
},
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 44.sp,
lineHeight = 44.sp,
textAlign = TextAlign.Center,
letterSpacing = (-0.5).sp
)
Text(
text = "Diary",
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
fontSize = 44.sp,
lineHeight = 45.sp,
color = FgColor,
textAlign = TextAlign.Center,
letterSpacing = (-0.5).sp
)
Spacer(Modifier.height(18.dp))
// Tagline
Text(
text = "A quiet ledger for your body —\nsymptoms, cycle, and the days between.",
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 15.sp,
lineHeight = 22.sp,
color = FgMutedColor,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(28.dp))
// Ornament divider
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = BorderSoftColor,
thickness = 0.5.dp
)
Text(
text = "",
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 14.sp,
color = AccentColor
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = BorderSoftColor,
thickness = 0.5.dp
)
}
Spacer(Modifier.height(28.dp))
// Feature list
val features = listOf(
"" to "Track period, fertility & ovulation",
"" to "Log symptoms, mood, intimacy",
"" to "Stays on your device. No cloud.",
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
features.forEach { (bullet, label) ->
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = bullet,
fontFamily = JetBrainsMono,
fontSize = 12.sp,
color = AccentColor,
modifier = Modifier.padding(top = 2.dp)
)
Text(
text = label,
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 14.sp,
lineHeight = 20.sp,
color = FgColor
)
}
}
}
Spacer(Modifier.height(40.dp))
// Primary button
Box(
modifier = Modifier
.fillMaxWidth()
.background(AccentColor, RoundedCornerShape(999.dp))
.clickable(onClick = onNext)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center
) {
Text(
"Begin writing",
fontFamily = InstrumentSans,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
letterSpacing = 0.1.sp,
color = AccentOnColor
)
}
Spacer(Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onNext)
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
"I have an existing diary",
fontFamily = InstrumentSans,
fontSize = 13.sp,
color = FgMutedColor
)
}
}
}
// ─── Profile setup step ───────────────────────────────────────────────────────
@Composable @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
)
} }
} }
} }

View File

@@ -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( Column(
topBar = { modifier = Modifier
TopAppBar( .fillMaxSize()
title = { Text("Settings") }, .background(BgColor)
navigationIcon = { ) {
IconButton(onClick = onBack) { DiaryTopBar(showBack = true, onBack = onBack, title = "Settings")
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)
.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()
// Theme
ListItem(
headlineContent = { Text("App theme") },
trailingContent = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Light", "Dark", "System").forEach { theme ->
val key = theme.uppercase()
FilterChip(
selected = state.appTheme == key,
onClick = { viewModel.setAppTheme(key) },
label = { Text(theme) }
)
}
}
}
)
}
// Profile settings
state.profiles.forEach { profile ->
SettingsSection("Profile: ${profile.name}") {
ProfileSettingsCard(
profile = profile,
onNameChange = { viewModel.updateProfileName(profile.id, it) },
onColorChange = { viewModel.updateProfileColor(profile.id, it) }
) )
HorizontalDivider() }
ListItem( HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
headlineContent = { Text("Clear profile data", color = MaterialTheme.colorScheme.error) }, AubergineSettingRow(label = "App theme") {
supportingContent = { Text("Removes all logs for this profile") }, AubergineTabs(
modifier = Modifier.clickable { clearProfileTarget = profile.id } options = listOf("LIGHT" to "LIGHT", "DARK" to "DARK", "AUTO" to "SYSTEM"),
selectedKey = state.appTheme,
onSelect = viewModel::setAppTheme
) )
} }
} }
// Data management // ── II. Profiles ──────────────────────────────────────────────────
SettingsSection("Data") { SectionLabel(title = "Profiles", num = "II.")
ListItem( Column(modifier = Modifier.padding(horizontal = 18.dp)) {
headlineContent = { Text("Clear all data", color = MaterialTheme.colorScheme.error) }, state.profiles.forEach { profile ->
supportingContent = { Text("Removes all profiles and data — cannot be undone") }, ProfileRow(
modifier = Modifier.clickable { showClearAll = true } profile = profile,
onNameChange = { viewModel.updateProfileName(profile.id, it) },
onColorChange = { viewModel.updateProfileColor(profile.id, it) },
onClearData = { clearProfileTarget = profile.id }
)
Spacer(Modifier.height(8.dp))
}
}
// ── III. Data ─────────────────────────────────────────────────────
SectionLabel(title = "Data", num = "III.")
DiarySettingsCard(modifier = Modifier.padding(horizontal = 18.dp)) {
AubergineSettingRow(label = "Export as JSON", trailing = "")
HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
AubergineSettingRow(
label = "Clear all data",
labelColor = Color(0xFFD06060),
trailing = "",
onClick = { showClearAll = true }
) )
} }
Spacer(Modifier.height(32.dp)) // ── Colophon ──────────────────────────────────────────────────────
Text( Column(
"H&S Diary · All data stored locally on this device.", modifier = Modifier
style = MaterialTheme.typography.bodySmall, .fillMaxWidth()
color = MaterialTheme.colorScheme.onSurfaceVariant, .padding(horizontal = 18.dp, vertical = 32.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) horizontalAlignment = Alignment.CenterHorizontally
) ) {
Text(
text = "H&S Diary — kept privately,\non this device alone.",
fontFamily = CormorantGaramond,
fontStyle = FontStyle.Italic,
fontSize = 13.sp,
lineHeight = 19.sp,
color = FgSubtleColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
text = "v 2.6.0",
fontFamily = JetBrainsMono,
fontSize = 9.sp,
color = FgFaintColor,
letterSpacing = 2.sp
)
}
} }
} }
@@ -135,106 +132,299 @@ fun SettingsScreen(
val profile = state.profiles.find { it.id == profileId } 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
if (nameEditMode) { .fillMaxWidth()
OutlinedTextField( .background(SurfaceColor, RoundedCornerShape(14.dp))
value = editingName, .border(1.dp, BorderSoftColor, RoundedCornerShape(14.dp))
onValueChange = { if (it.length <= 32) editingName = it }, .padding(14.dp)
label = { Text("Name") }, ) {
singleLine = true, Column {
modifier = Modifier.fillMaxWidth(), Row(
trailingIcon = { modifier = Modifier
TextButton(onClick = { .fillMaxWidth()
onNameChange(editingName) .clickable { expanded = !expanded },
nameEditMode = false verticalAlignment = Alignment.CenterVertically,
}) { Text("Save") } 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(
} else { text = if (expanded) "" else "",
ListItem( fontFamily = JetBrainsMono,
headlineContent = { Text(profile.name) }, fontSize = 14.sp,
supportingContent = { Text("Tap to edit name") }, color = FgFaintColor
modifier = Modifier.clickable { nameEditMode = true } )
) }
}
Spacer(Modifier.height(8.dp)) if (expanded) {
Text("Avatar color", style = MaterialTheme.typography.labelMedium) Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(8.dp)) HorizontalDivider(color = BorderSoftColor, thickness = 0.5.dp)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 4.dp)) { Spacer(Modifier.height(12.dp))
colorHexes.forEachIndexed { idx, hex ->
val color = parseColor(hex) // Name edit
val isSelected = profile.avatarColor == hex if (nameEditMode) {
Box( OutlinedTextField(
value = editingName,
onValueChange = { if (it.length <= 32) editingName = it },
label = { Text("Name", fontFamily = InstrumentSans, fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AccentColor,
unfocusedBorderColor = BorderColor,
focusedTextColor = FgColor,
unfocusedTextColor = FgColor,
focusedLabelColor = AccentDimColor,
unfocusedLabelColor = FgSubtleColor
),
trailingIcon = {
TextButton(onClick = {
onNameChange(editingName)
nameEditMode = false
}) {
Text("Save", fontFamily = InstrumentSans, color = AccentColor)
}
}
)
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { nameEditMode = true }
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Name", fontFamily = InstrumentSans, fontSize = 13.sp, color = FgColor)
Text(profile.name, fontFamily = InstrumentSans, fontSize = 13.sp, color = FgMutedColor)
}
}
Spacer(Modifier.height(12.dp))
Text(
"AVATAR COLOUR",
fontFamily = JetBrainsMono,
fontSize = 9.sp,
color = FgFaintColor,
letterSpacing = 1.sp
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
colorHexes.forEach { hex ->
val color = parseColor(hex)
val isSelected = profile.avatarColor == hex
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(color)
.then(
if (isSelected)
Modifier.border(2.dp, FgColor, CircleShape)
else Modifier
)
.clickable { onColorChange(hex) }
)
}
}
Spacer(Modifier.height(12.dp))
Text(
text = "Clear profile data",
fontFamily = InstrumentSans,
fontSize = 13.sp,
color = Color(0xFFD06060),
modifier = Modifier modifier = Modifier
.size(32.dp) .clickable(onClick = onClearData)
.clip(CircleShape) .padding(vertical = 4.dp)
.background(color) )
.then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier) }
.clickable { onColorChange(hex) } }
}
}
// ─── Helper composables ───────────────────────────────────────────────────────
@Composable
private fun DiarySettingsCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(SurfaceColor, RoundedCornerShape(14.dp))
.border(1.dp, BorderSoftColor, RoundedCornerShape(14.dp))
) {
Column(content = content)
}
}
@Composable
private fun AubergineSettingRow(
label: String,
labelColor: Color = FgColor,
hint: String? = null,
trailing: String? = null,
onClick: (() -> Unit)? = null,
control: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, fontFamily = InstrumentSans, fontSize = 13.sp, color = labelColor)
if (hint != null) {
Text(hint, fontFamily = InstrumentSans, fontSize = 11.sp, color = FgSubtleColor)
}
}
control?.invoke()
if (trailing != null) {
Text(
text = trailing,
fontFamily = CormorantGaramond,
fontSize = 14.sp,
color = FgFaintColor
)
}
}
}
@Composable
private fun <T> AubergineTabs(
options: List<Pair<String, T>>,
selectedKey: T,
onSelect: (T) -> Unit
) {
Row(
modifier = Modifier
.background(SurfaceLoColor, RoundedCornerShape(999.dp))
.border(1.dp, BorderSoftColor, RoundedCornerShape(999.dp))
.padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
options.forEach { (label, key) ->
val sel = key == selectedKey
Box(
modifier = Modifier
.background(
if (sel) SurfaceHiColor else Color.Transparent,
RoundedCornerShape(999.dp)
)
.clickable { onSelect(key) }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = label,
fontFamily = JetBrainsMono,
fontSize = 11.sp,
letterSpacing = 0.2.sp,
color = if (sel) FgColor else FgSubtleColor
) )
} }
} }

View File

@@ -2,30 +2,52 @@ package com.hsdiary.ui.theme
import androidx.compose.ui.graphics.Color 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

View File

@@ -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 { MaterialTheme(
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { colorScheme = AubergineNocturne,
val context = LocalContext.current typography = Typography,
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) content = content,
} )
darkTheme -> DarkColors
else -> LightColors
} }
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
} }

View File

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

View File

@@ -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"
) { padding -> TrendsRange.MONTHS_3 -> "last 90 days"
TrendsRange.MONTHS_6 -> "last 6 months"
TrendsRange.ALL -> "all time"
}
)
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.values().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" Box(
TrendsRange.ALL -> "All" modifier = Modifier
}, style = MaterialTheme.typography.labelSmall) .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) Text(
if (frequency.isRecurring) { text = freq.definition.displayName,
Badge(containerColor = MaterialTheme.colorScheme.tertiaryContainer) { fontFamily = CormorantGaramond,
Text("Recurring", style = MaterialTheme.typography.labelSmall) 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} times · avg rating ${"%.1f".format(frequency.avgRating)}/5", text = "${freq.count}× · AVG ${"%.1f".format(freq.avgRating)}/5",
style = MaterialTheme.typography.bodySmall, fontFamily = JetBrainsMono,
color = MaterialTheme.colorScheme.onSurfaceVariant fontSize = 10.sp,
color = FgSubtleColor,
letterSpacing = 0.6.sp
) )
} }
// Sparkline bars
if (freq.weeklyData.isNotEmpty()) {
val maxW = (freq.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f)
Row(
modifier = Modifier.height(26.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
freq.weeklyData.forEach { w ->
val barFraction = (w / maxW).coerceAtLeast(0.08f)
Box(
modifier = Modifier
.width(6.dp)
.fillMaxHeight(barFraction)
.background(
if (accent) AccentColor else SurfaceHiColor,
RoundedCornerShape(1.dp)
)
.border(0.5.dp, BorderSoftColor, RoundedCornerShape(1.dp))
)
}
}
Spacer(Modifier.width(6.dp))
}
// Big count number
Text( Text(
"×${frequency.count}", text = freq.count.toString(),
style = MaterialTheme.typography.titleLarge, fontFamily = CormorantGaramond,
fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.primary fontWeight = FontWeight.Medium,
fontSize = 22.sp,
color = if (accent) AccentColor else FgMutedColor
) )
} }
if (isSelected && frequency.weeklyData.isNotEmpty()) { // Expanded weekly chart
if (isSelected && freq.weeklyData.isNotEmpty()) {
Spacer(Modifier.height(12.dp)) 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))
) )
} }
} }

View File

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

View File

@@ -24,6 +24,7 @@ compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling
compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-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" }