打发
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package com.pinkbear.pinkov.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.pinkbear.pinkov.data.entity.DailyRecord
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface DailyRecordDao {
|
||||
|
||||
/** Get the record for a specific date. */
|
||||
@Query("SELECT * FROM daily_records WHERE date = :date")
|
||||
fun getRecord(date: Long): Flow<DailyRecord?>
|
||||
|
||||
/** Get all records within a date range (epoch days, inclusive). */
|
||||
@Query("SELECT * FROM daily_records WHERE date BETWEEN :start AND :end")
|
||||
fun getRecordsInRange(start: Long, end: Long): Flow<List<DailyRecord>>
|
||||
|
||||
/** Get all dates marked as period start. */
|
||||
@Query("SELECT * FROM daily_records WHERE is_period_start = 1")
|
||||
fun getAllPeriodStarts(): Flow<List<DailyRecord>>
|
||||
|
||||
/** Get the record for a specific date (one-shot, suspend). */
|
||||
@Query("SELECT * FROM daily_records WHERE date = :date")
|
||||
suspend fun getRecordOnce(date: Long): DailyRecord?
|
||||
|
||||
/** Get all records within a date range (one-shot, suspend). */
|
||||
@Query("SELECT * FROM daily_records WHERE date BETWEEN :start AND :end")
|
||||
suspend fun getRecordsInRangeOnce(start: Long, end: Long): List<DailyRecord>
|
||||
|
||||
/** Insert or replace a record. */
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(record: DailyRecord)
|
||||
|
||||
/** Delete a record. */
|
||||
@Delete
|
||||
suspend fun delete(record: DailyRecord)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.pinkbear.pinkov.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.pinkbear.pinkov.data.entity.PeriodSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PeriodSettingsDao {
|
||||
|
||||
/** Observe the current settings (single row, id=1). */
|
||||
@Query("SELECT * FROM period_settings WHERE id = 1")
|
||||
fun getSettings(): Flow<PeriodSettings?>
|
||||
|
||||
/** Upsert settings (insert or replace the single row). */
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertSettings(settings: PeriodSettings)
|
||||
}
|
||||
@@ -4,26 +4,69 @@ import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.pinkbear.pinkov.data.dao.DailyRecordDao
|
||||
import com.pinkbear.pinkov.data.dao.PeriodSettingsDao
|
||||
import com.pinkbear.pinkov.data.dao.ScanRecordDao
|
||||
import com.pinkbear.pinkov.data.entity.DailyRecord
|
||||
import com.pinkbear.pinkov.data.entity.PeriodSettings
|
||||
import com.pinkbear.pinkov.data.entity.ScanRecord
|
||||
|
||||
@Database(entities = [ScanRecord::class], version = 1, exportSchema = false)
|
||||
@Database(
|
||||
entities = [ScanRecord::class, PeriodSettings::class, DailyRecord::class],
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun scanRecordDao(): ScanRecordDao
|
||||
abstract fun periodSettingsDao(): PeriodSettingsDao
|
||||
abstract fun dailyRecordDao(): DailyRecordDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `period_settings` (
|
||||
`id` INTEGER NOT NULL,
|
||||
`period_length` INTEGER NOT NULL DEFAULT 5,
|
||||
`cycle_length` INTEGER NOT NULL DEFAULT 28,
|
||||
`last_period_date` INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(`id`)
|
||||
)
|
||||
""".trimIndent())
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `daily_records` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`date` INTEGER NOT NULL,
|
||||
`is_period_start` INTEGER NOT NULL DEFAULT 0,
|
||||
`had_sex` INTEGER NOT NULL DEFAULT 0,
|
||||
`mood` TEXT,
|
||||
`discharge` TEXT,
|
||||
`note` TEXT
|
||||
)
|
||||
""".trimIndent())
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_records_date` ON `daily_records` (`date`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"pinkov_database"
|
||||
).build().also { INSTANCE = it }
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.pinkbear.pinkov.data.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Per-date check-in record. One row per date (unique on date).
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "daily_records",
|
||||
indices = [Index(value = ["date"], unique = true)]
|
||||
)
|
||||
data class DailyRecord(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
/** Date of this record, as epoch day (LocalDate.toEpochDay()). */
|
||||
@ColumnInfo(name = "date")
|
||||
val date: Long,
|
||||
|
||||
/** User marked "月经来了" (period started) on this date. */
|
||||
@ColumnInfo(name = "is_period_start")
|
||||
val isPeriodStart: Boolean = false,
|
||||
|
||||
/** User recorded sexual activity on this date. */
|
||||
@ColumnInfo(name = "had_sex")
|
||||
val hadSex: Boolean = false,
|
||||
|
||||
/** Mood tag (e.g. 焦虑, 期待, 低落, 放松). Null = not recorded. */
|
||||
@ColumnInfo(name = "mood")
|
||||
val mood: String? = null,
|
||||
|
||||
/** Cervical mucus / discharge description. Null = not recorded. */
|
||||
@ColumnInfo(name = "discharge")
|
||||
val discharge: String? = null,
|
||||
|
||||
/** Free-text notes for this date. */
|
||||
@ColumnInfo(name = "note")
|
||||
val note: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.pinkbear.pinkov.data.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Single-row table storing the user's period configuration.
|
||||
*/
|
||||
@Entity(tableName = "period_settings")
|
||||
data class PeriodSettings(
|
||||
@PrimaryKey
|
||||
val id: Int = 1,
|
||||
|
||||
/** Duration of menstrual bleeding in days (3-15). Default 5. */
|
||||
@ColumnInfo(name = "period_length")
|
||||
val periodLength: Int = 5,
|
||||
|
||||
/** Average menstrual cycle length in days (17-31). Default 28. */
|
||||
@ColumnInfo(name = "cycle_length")
|
||||
val cycleLength: Int = 28,
|
||||
|
||||
/** First day of the most recent actual period, as epoch day (LocalDate.toEpochDay()). */
|
||||
@ColumnInfo(name = "last_period_date")
|
||||
val lastPeriodDate: Long = 0L,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.pinkbear.pinkov.data.model
|
||||
|
||||
import com.pinkbear.pinkov.data.entity.PeriodSettings
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
/**
|
||||
* Pure-Kotlin engine that computes menstrual / ovulation calendar phases.
|
||||
*
|
||||
* Formula (per standard gynecology):
|
||||
* nextPeriod = lastPeriodStart + cycleLength
|
||||
* ovulation = nextPeriod − 14
|
||||
* fertileWin = [ovulation − 5, ovulation + 1]
|
||||
* predicted = [nextPeriod, nextPeriod + periodLength − 1]
|
||||
*
|
||||
* When an actual period (user-tagged "月经来了") overlaps a predicted period,
|
||||
* actual takes priority.
|
||||
*/
|
||||
object CycleCalculator {
|
||||
|
||||
/**
|
||||
* Compute the [CycleInfo] for a given month.
|
||||
*
|
||||
* @param yearMonth the month to calculate phases for
|
||||
* @param settings user's period settings (cycle length, period length, last period date)
|
||||
* @param actualPeriodStarts dates the user marked as "月经来了" (period start)
|
||||
* @return [CycleInfo] containing the phase for every date in the month that has one
|
||||
*/
|
||||
fun calculate(
|
||||
yearMonth: YearMonth,
|
||||
settings: PeriodSettings?,
|
||||
actualPeriodStarts: Set<LocalDate>,
|
||||
): CycleInfo {
|
||||
val phases = mutableMapOf<LocalDate, DayPhase>()
|
||||
|
||||
if (settings == null || settings.lastPeriodDate == 0L) {
|
||||
return CycleInfo(phases)
|
||||
}
|
||||
|
||||
val lastPeriodDate = LocalDate.ofEpochDay(settings.lastPeriodDate)
|
||||
val periodLength = settings.periodLength
|
||||
val cycleLength = settings.cycleLength
|
||||
|
||||
// 1. Actual period ranges (deep pink) — highest priority
|
||||
for (start in actualPeriodStarts) {
|
||||
for (i in 0 until periodLength) {
|
||||
val date = start.plusDays(i.toLong())
|
||||
if (date.month == yearMonth.month && date.year == yearMonth.year) {
|
||||
phases[date] = DayPhase.ACTUAL_PERIOD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generate predicted cycles from lastPeriodDate forward
|
||||
// We walk cycle-by-cycle until we pass the target month
|
||||
val predictedPeriodDates = mutableSetOf<LocalDate>()
|
||||
val ovulationDates = mutableMapOf<LocalDate, DayPhase>()
|
||||
|
||||
var currentStart = lastPeriodDate
|
||||
// Go back enough cycles so we cover the target month even if it's in the past
|
||||
// Then go forward until we're past the target month
|
||||
val cyclesToGenerate = 24 // generous: 2 years of cycles
|
||||
val startCycle = currentStart
|
||||
|
||||
for (cycleIndex in 0 until cyclesToGenerate) {
|
||||
val cycleStart = startCycle.plusDays((cycleIndex * cycleLength).toLong())
|
||||
val nextPeriodStart = cycleStart.plusDays(cycleLength.toLong())
|
||||
val ovulationDay = nextPeriodStart.minusDays(14)
|
||||
|
||||
// Predicted period: [cycleStart, cycleStart + periodLength - 1]
|
||||
// Actually, the FIRST predicted period is the one AFTER the last known period.
|
||||
// The current cycle's period starts at nextPeriodStart.
|
||||
for (i in 0 until periodLength) {
|
||||
val date = nextPeriodStart.plusDays(i.toLong())
|
||||
if (date.month == yearMonth.month && date.year == yearMonth.year) {
|
||||
predictedPeriodDates.add(date)
|
||||
}
|
||||
}
|
||||
|
||||
// Ovulation day
|
||||
if (ovulationDay.month == yearMonth.month && ovulationDay.year == yearMonth.year) {
|
||||
ovulationDates[ovulationDay] = DayPhase.OVULATION_DAY
|
||||
}
|
||||
|
||||
// Ovulation range: [ovulationDay - 5, ovulationDay + 1]
|
||||
for (i in -5..1) {
|
||||
val date = ovulationDay.plusDays(i.toLong())
|
||||
if (date.month == yearMonth.month && date.year == yearMonth.year) {
|
||||
// Don't overwrite ovulation day
|
||||
if (!ovulationDates.containsKey(date)) {
|
||||
ovulationDates[date] = DayPhase.OVULATION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge: predicted period (lower priority)
|
||||
for (date in predictedPeriodDates) {
|
||||
if (!phases.containsKey(date)) {
|
||||
phases[date] = DayPhase.PREDICTED_PERIOD
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Merge: ovulation / fertile window (lowest priority among non-NONE)
|
||||
for ((date, phase) in ovulationDates) {
|
||||
if (!phases.containsKey(date)) {
|
||||
phases[date] = phase
|
||||
}
|
||||
}
|
||||
|
||||
return CycleInfo(phases)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.pinkbear.pinkov.data.model
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Computed cycle information for a calendar month.
|
||||
*
|
||||
* @property phases map from date to its [DayPhase] — only dates with a phase are present.
|
||||
*/
|
||||
data class CycleInfo(
|
||||
val phases: Map<LocalDate, DayPhase>
|
||||
) {
|
||||
/** Get the phase for a given date, or [DayPhase.NONE] if not set. */
|
||||
fun getPhase(date: LocalDate): DayPhase = phases[date] ?: DayPhase.NONE
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.pinkbear.pinkov.data.model
|
||||
|
||||
/**
|
||||
* Phase / state of a single calendar day in the fertility calendar.
|
||||
*/
|
||||
enum class DayPhase {
|
||||
/** No special state. */
|
||||
NONE,
|
||||
|
||||
/** Actual menstrual period — user marked "月经来了" → deep pink background. */
|
||||
ACTUAL_PERIOD,
|
||||
|
||||
/** Predicted menstrual period — formula-based → light pink background. */
|
||||
PREDICTED_PERIOD,
|
||||
|
||||
/** Ovulation / fertile window (排卵日 ± several days) → purple background. */
|
||||
OVULATION,
|
||||
|
||||
/** Ovulation day itself → purple background + hexagon marker. */
|
||||
OVULATION_DAY,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.pinkbear.pinkov.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.pinkbear.pinkov.data.entity.DailyRecord
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom panel showing check-in items for the selected calendar date.
|
||||
*
|
||||
* When no date is selected, shows a hint. When a date is selected,
|
||||
* shows the "月经来了" switch and (in future) more check-in items.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckInPanel(
|
||||
selectedDate: LocalDate?,
|
||||
record: DailyRecord?,
|
||||
onPeriodStartChanged: (LocalDate, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
if (selectedDate == null) {
|
||||
Text(
|
||||
text = "点击日历上的日期\n查看或记录当天状态",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 20.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = selectedDate.format(formatter),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// ---- 月经来了 ----
|
||||
CheckInSwitchRow(
|
||||
label = "月经来了",
|
||||
checked = record?.isPeriodStart ?: false,
|
||||
onCheckedChange = { checked ->
|
||||
onPeriodStartChanged(selectedDate, checked)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
// ---- Placeholder items (future) ----
|
||||
PlaceholderCheckInRow(label = "爱爱")
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
PlaceholderCheckInRow(label = "心情")
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
PlaceholderCheckInRow(label = "白带")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckInSwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderCheckInRow(label: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "—",
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.pinkbear.pinkov.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.pinkbear.pinkov.data.model.CycleInfo
|
||||
import com.pinkbear.pinkov.data.model.DayPhase
|
||||
import com.pinkbear.pinkov.ui.theme.FertilityColors
|
||||
import io.github.boguszpawlowski.composecalendar.day.DayState
|
||||
import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState
|
||||
|
||||
/**
|
||||
* Custom day-cell composable for the fertility calendar.
|
||||
*
|
||||
* Renders:
|
||||
* - Colored background based on [CycleInfo] phase
|
||||
* - Blue border for today
|
||||
* - Filled border for selected date
|
||||
* - Hexagon marker in bottom-right for ovulation day
|
||||
* - Dimmed text for adjacent-month days
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.FertilityDayContent(
|
||||
state: DayState<DynamicSelectionState>,
|
||||
cycleInfo: CycleInfo,
|
||||
) {
|
||||
val date = state.date
|
||||
val phase = cycleInfo.getPhase(date)
|
||||
val isSelected = state.selectionState.isDateSelected(date)
|
||||
|
||||
val backgroundColor = when (phase) {
|
||||
DayPhase.ACTUAL_PERIOD -> FertilityColors.actualPeriod
|
||||
DayPhase.PREDICTED_PERIOD -> FertilityColors.predictedPeriod
|
||||
DayPhase.OVULATION, DayPhase.OVULATION_DAY -> FertilityColors.ovulation
|
||||
DayPhase.NONE -> Color.Transparent
|
||||
}
|
||||
|
||||
val textColor = when {
|
||||
!state.isFromCurrentMonth -> FertilityColors.adjacentMonthText
|
||||
phase == DayPhase.ACTUAL_PERIOD -> FertilityColors.textOnDark
|
||||
else -> Color.Unspecified // use default (M3 onSurface)
|
||||
}
|
||||
|
||||
val borderModifier = when {
|
||||
isSelected -> Modifier.border(2.dp, FertilityColors.selectedBorder, RoundedCornerShape(6.dp))
|
||||
state.isCurrentDay -> Modifier.border(1.5.dp, FertilityColors.todayBorder, RoundedCornerShape(6.dp))
|
||||
else -> Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.then(borderModifier)
|
||||
.background(color = backgroundColor, shape = RoundedCornerShape(6.dp))
|
||||
.clickable { state.selectionState.onDateSelected(date) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date.dayOfMonth.toString(),
|
||||
color = textColor,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (state.isCurrentDay) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
// Ovulation day hexagon marker
|
||||
if (phase == DayPhase.OVULATION_DAY) {
|
||||
HexagonMarker(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(14.dp),
|
||||
color = FertilityColors.ovulationDayMarker,
|
||||
size = 14.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.pinkbear.pinkov.ui.component
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A small filled hexagon marker drawn at the bottom-right of a day cell
|
||||
* to indicate ovulation day.
|
||||
*/
|
||||
@Composable
|
||||
fun HexagonMarker(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color(0xFF7B1FA2),
|
||||
size: Dp = 14.dp,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
val w = size.toPx()
|
||||
val h = size.toPx()
|
||||
val cx = w / 2f
|
||||
val cy = h / 2f
|
||||
val r = w / 2f
|
||||
|
||||
val path = Path().apply {
|
||||
// Regular hexagon: vertices at 30° + k*60°
|
||||
for (i in 0 until 6) {
|
||||
val angle = Math.toRadians(30.0 + i * 60.0)
|
||||
val x = cx + r * kotlin.math.cos(angle).toFloat()
|
||||
val y = cy + r * kotlin.math.sin(angle).toFloat()
|
||||
if (i == 0) moveTo(x, y) else lineTo(x, y)
|
||||
}
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = color)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.pinkbear.pinkov.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.pinkbear.pinkov.data.entity.PeriodSettings
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Dialog for configuring period settings.
|
||||
*
|
||||
* Contains:
|
||||
* - Period length wheel picker (3–15 days)
|
||||
* - Cycle length wheel picker (17–31 days)
|
||||
* - Last period start date picker
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PeriodSettingsDialog(
|
||||
currentSettings: PeriodSettings?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (PeriodSettings) -> Unit,
|
||||
) {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE)
|
||||
|
||||
// Local editable state, initialized from current settings or defaults
|
||||
var periodLength by remember(currentSettings) {
|
||||
mutableIntStateOf(currentSettings?.periodLength ?: 5)
|
||||
}
|
||||
var cycleLength by remember(currentSettings) {
|
||||
mutableIntStateOf(currentSettings?.cycleLength ?: 28)
|
||||
}
|
||||
var lastPeriodDate by remember(currentSettings) {
|
||||
mutableStateOf(
|
||||
if (currentSettings != null && currentSettings.lastPeriodDate > 0L)
|
||||
LocalDate.ofEpochDay(currentSettings.lastPeriodDate)
|
||||
else
|
||||
LocalDate.now()
|
||||
)
|
||||
}
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "经期设置",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// ---- Period length ----
|
||||
Text(
|
||||
text = "设置您的月经大概维持几天?",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
WheelPicker(
|
||||
items = (3..15).toList(),
|
||||
selected = periodLength,
|
||||
onSelected = { periodLength = it },
|
||||
visibleItems = 5,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// ---- Cycle length ----
|
||||
Text(
|
||||
text = "两次月经开始日大概间隔多久?",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
WheelPicker(
|
||||
items = (17..31).toList(),
|
||||
selected = cycleLength,
|
||||
onSelected = { cycleLength = it },
|
||||
visibleItems = 5,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// ---- Last period start date ----
|
||||
Text(
|
||||
text = "最近一次月经开始时间",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = { showDatePicker = true }) {
|
||||
Text(
|
||||
text = lastPeriodDate.format(dateFormatter),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onSave(
|
||||
PeriodSettings(
|
||||
id = 1,
|
||||
periodLength = periodLength,
|
||||
cycleLength = cycleLength,
|
||||
lastPeriodDate = lastPeriodDate.toEpochDay(),
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Text("保存")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ---- Date picker sub-dialog ----
|
||||
if (showDatePicker) {
|
||||
val datePickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = lastPeriodDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
datePickerState.selectedDateMillis?.let { millis ->
|
||||
lastPeriodDate = Instant.ofEpochMilli(millis)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}
|
||||
showDatePicker = false
|
||||
}) {
|
||||
Text("确定")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDatePicker = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.pinkbear.pinkov.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A vertical wheel picker that snaps items to the center position.
|
||||
*
|
||||
* Uses [LazyColumn] with [rememberSnapFlingBehavior] for a native-feeling scroll wheel.
|
||||
* Gradient fades at top and bottom create the classic wheel illusion.
|
||||
*
|
||||
* @param items list of integer values to display
|
||||
* @param selected currently selected value
|
||||
* @param onSelected callback when the user selects a new value
|
||||
* @param modifier modifier for the outer container
|
||||
* @param itemHeight height of each item row
|
||||
* @param visibleItems number of items visible at once (must be odd for symmetry)
|
||||
*/
|
||||
@Composable
|
||||
fun WheelPicker(
|
||||
items: List<Int>,
|
||||
selected: Int,
|
||||
onSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
itemHeight: Dp = 48.dp,
|
||||
visibleItems: Int = 5,
|
||||
) {
|
||||
require(visibleItems % 2 == 1) { "visibleItems must be odd for center alignment" }
|
||||
val totalHeight = itemHeight * visibleItems
|
||||
|
||||
val initialIndex = items.indexOf(selected).coerceAtLeast(0)
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = initialIndex)
|
||||
|
||||
// Detect center item from layout info
|
||||
val centerItemIndex by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
if (layoutInfo.visibleItemsInfo.isEmpty()) return@derivedStateOf initialIndex
|
||||
|
||||
val viewportCenter = layoutInfo.viewportStartOffset + layoutInfo.viewportSize.height / 2
|
||||
layoutInfo.visibleItemsInfo
|
||||
.minByOrNull { itemInfo ->
|
||||
val itemCenter = itemInfo.offset + itemInfo.size / 2
|
||||
abs(itemCenter - viewportCenter)
|
||||
}?.index ?: initialIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Notify when center item changes
|
||||
LaunchedEffect(centerItemIndex) {
|
||||
if (centerItemIndex in items.indices) {
|
||||
onSelected(items[centerItemIndex])
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.height(totalHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Wheel items
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = listState),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Top spacer to allow first item to reach center
|
||||
item(key = "top_spacer") {
|
||||
Box(modifier = Modifier.height(totalHeight / 2 - itemHeight / 2))
|
||||
}
|
||||
|
||||
itemsIndexed(items) { index, value ->
|
||||
val isCenter = index == centerItemIndex
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "${value}天",
|
||||
fontSize = if (isCenter) 20.sp else 16.sp,
|
||||
fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isCenter)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom spacer to allow last item to reach center
|
||||
item(key = "bottom_spacer") {
|
||||
Box(modifier = Modifier.height(totalHeight / 2 - itemHeight / 2))
|
||||
}
|
||||
}
|
||||
|
||||
// Center highlight indicator line
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.padding(horizontal = 32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)),
|
||||
)
|
||||
|
||||
// Gradient fades (top & bottom)
|
||||
WheelFadeGradient(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.surface,
|
||||
Color.Transparent,
|
||||
),
|
||||
),
|
||||
height = itemHeight,
|
||||
)
|
||||
WheelFadeGradient(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
),
|
||||
height = itemHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.WheelFadeGradient(
|
||||
modifier: Modifier = Modifier,
|
||||
brush: Brush,
|
||||
height: Dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.background(brush),
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -17,10 +18,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.pinkbear.pinkov.ui.component.PeriodSettingsDialog
|
||||
import com.pinkbear.pinkov.ui.theme.PinkOVTheme
|
||||
|
||||
@Composable
|
||||
@@ -29,6 +32,11 @@ fun MinePage(modifier: Modifier = Modifier) {
|
||||
var isLoggedIn by remember { mutableStateOf(false) }
|
||||
var userName by remember { mutableStateOf("") }
|
||||
|
||||
// Period settings dialog
|
||||
var showPeriodSettings by remember { mutableStateOf(false) }
|
||||
val recordsViewModel: RecordsViewModel = viewModel()
|
||||
val periodSettings by recordsViewModel.settings.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -105,6 +113,17 @@ fun MinePage(modifier: Modifier = Modifier) {
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
// ---- 经期设置 (above 设置) ----
|
||||
MenuItemRow(
|
||||
icon = Icons.Default.Favorite,
|
||||
title = "经期设置",
|
||||
onClick = { showPeriodSettings = true }
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
MenuItemRow(
|
||||
icon = Icons.Default.Settings,
|
||||
title = "设置",
|
||||
@@ -125,6 +144,18 @@ fun MinePage(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Period Settings Dialog ----
|
||||
if (showPeriodSettings) {
|
||||
PeriodSettingsDialog(
|
||||
currentSettings = periodSettings,
|
||||
onDismiss = { showPeriodSettings = false },
|
||||
onSave = { settings ->
|
||||
recordsViewModel.saveSettings(settings)
|
||||
showPeriodSettings = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,4 +205,4 @@ private fun MinePagePreview() {
|
||||
PinkOVTheme {
|
||||
MinePage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,205 @@
|
||||
package com.pinkbear.pinkov.ui.page
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.pinkbear.pinkov.data.model.CycleCalculator
|
||||
import com.pinkbear.pinkov.ui.component.CheckInPanel
|
||||
import com.pinkbear.pinkov.ui.component.FertilityDayContent
|
||||
import com.pinkbear.pinkov.ui.component.HexagonMarker
|
||||
import com.pinkbear.pinkov.ui.theme.FertilityColors
|
||||
import com.pinkbear.pinkov.ui.theme.PinkOVTheme
|
||||
import io.github.boguszpawlowski.composecalendar.SelectableCalendar
|
||||
import io.github.boguszpawlowski.composecalendar.rememberSelectableCalendarState
|
||||
import io.github.boguszpawlowski.composecalendar.selection.SelectionMode
|
||||
import java.time.LocalDate
|
||||
|
||||
@Composable
|
||||
fun RecordsPage(modifier: Modifier = Modifier) {
|
||||
fun RecordsPage(
|
||||
modifier: Modifier = Modifier,
|
||||
recordsViewModel: RecordsViewModel = viewModel(),
|
||||
) {
|
||||
val calendarState = rememberSelectableCalendarState(
|
||||
initialSelectionMode = SelectionMode.Single,
|
||||
)
|
||||
|
||||
// Auto-select today's date on first composition
|
||||
val today = remember { LocalDate.now() }
|
||||
LaunchedEffect(Unit) {
|
||||
calendarState.selectionState.selection = listOf(today)
|
||||
}
|
||||
|
||||
// Data from ViewModel
|
||||
val settings by recordsViewModel.settings.collectAsState(initial = null)
|
||||
val actualPeriodStarts by recordsViewModel.allPeriodStarts.collectAsState()
|
||||
val selectedDate by remember {
|
||||
derivedStateOf { calendarState.selectionState.selection.firstOrNull() }
|
||||
}
|
||||
val currentMonth = calendarState.monthState.currentMonth
|
||||
|
||||
// Compute cycle info whenever inputs change
|
||||
val cycleInfo = remember(settings, actualPeriodStarts, currentMonth) {
|
||||
CycleCalculator.calculate(currentMonth, settings, actualPeriodStarts)
|
||||
}
|
||||
|
||||
// Record for the currently selected date
|
||||
val selectedRecord by recordsViewModel.getRecord(selectedDate ?: today)
|
||||
.collectAsState(initial = null)
|
||||
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "经期记录",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "日历与经期记录功能即将上线",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
// Calendar section
|
||||
SelectableCalendar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
calendarState = calendarState,
|
||||
horizontalSwipeEnabled = false,
|
||||
weekDaysScrollEnabled = false,
|
||||
dayContent = { dayState ->
|
||||
FertilityDayContent(
|
||||
state = dayState,
|
||||
cycleInfo = cycleInfo,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
// Color legend
|
||||
FertilityLegend()
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
// Check-in panel for the selected date
|
||||
CheckInPanel(
|
||||
selectedDate = selectedDate,
|
||||
record = selectedRecord,
|
||||
onPeriodStartChanged = { date, isStart ->
|
||||
recordsViewModel.setPeriodStart(date, isStart)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Color legend row showing the 4 calendar phase indicators.
|
||||
*/
|
||||
@Composable
|
||||
private fun FertilityLegend() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 月经期 — deep pink square
|
||||
LegendItem(
|
||||
indicator = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(FertilityColors.actualPeriod)
|
||||
)
|
||||
},
|
||||
label = "月经期",
|
||||
)
|
||||
|
||||
// 预测经期 — light pink square
|
||||
LegendItem(
|
||||
indicator = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(FertilityColors.predictedPeriod)
|
||||
)
|
||||
},
|
||||
label = "预测经期",
|
||||
)
|
||||
|
||||
// 排卵期 — purple square
|
||||
LegendItem(
|
||||
indicator = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(FertilityColors.ovulation)
|
||||
)
|
||||
},
|
||||
label = "排卵期",
|
||||
)
|
||||
|
||||
// 排卵日 — deep purple hexagon
|
||||
LegendItem(
|
||||
indicator = {
|
||||
HexagonMarker(
|
||||
modifier = Modifier.size(13.dp),
|
||||
color = FertilityColors.ovulationDayMarker,
|
||||
size = 13.dp,
|
||||
)
|
||||
},
|
||||
label = "排卵日",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendItem(
|
||||
indicator: @Composable () -> Unit,
|
||||
label: String,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
indicator()
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun RecordsPagePreview() {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.pinkbear.pinkov.ui.page
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pinkbear.pinkov.PinkApplication
|
||||
import com.pinkbear.pinkov.data.entity.DailyRecord
|
||||
import com.pinkbear.pinkov.data.entity.PeriodSettings
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for the Records (fertility calendar) page.
|
||||
*
|
||||
* Manages period settings, daily check-in records, and exposes
|
||||
* the data needed to compute [com.pinkbear.pinkov.data.model.CycleInfo].
|
||||
*/
|
||||
class RecordsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val db = (getApplication<PinkApplication>()).database
|
||||
private val settingsDao = db.periodSettingsDao()
|
||||
private val recordDao = db.dailyRecordDao()
|
||||
|
||||
/** Current period settings (single row). */
|
||||
val settings: Flow<PeriodSettings?> = settingsDao.getSettings()
|
||||
|
||||
/** All dates the user has marked as "月经来了" (period start). */
|
||||
val allPeriodStarts: StateFlow<Set<LocalDate>> = recordDao.getAllPeriodStarts()
|
||||
.map { records -> records.map { LocalDate.ofEpochDay(it.date) }.toSet() }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
/** All daily records (for the current month view). */
|
||||
fun getRecordsInRange(start: LocalDate, end: LocalDate): Flow<List<DailyRecord>> =
|
||||
recordDao.getRecordsInRange(start.toEpochDay(), end.toEpochDay())
|
||||
|
||||
/** Get a record for a specific date. */
|
||||
fun getRecord(date: LocalDate): Flow<DailyRecord?> =
|
||||
recordDao.getRecord(date.toEpochDay())
|
||||
|
||||
/** Toggle "月经来了" (period start) for a date. */
|
||||
fun setPeriodStart(date: LocalDate, isStart: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (isStart) {
|
||||
// Clear any existing period start in the same calendar month
|
||||
clearPeriodStartsInMonth(date)
|
||||
}
|
||||
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||
val record = existing?.copy(isPeriodStart = isStart)
|
||||
?: DailyRecord(date = date.toEpochDay(), isPeriodStart = isStart)
|
||||
recordDao.upsert(record)
|
||||
}
|
||||
}
|
||||
|
||||
/** Save or update period settings. */
|
||||
fun saveSettings(settings: PeriodSettings) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsDao.upsertSettings(settings)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all period-start flags within the same month as [date], except [date] itself. */
|
||||
private suspend fun clearPeriodStartsInMonth(date: LocalDate) {
|
||||
val monthStart = date.withDayOfMonth(1)
|
||||
val monthEnd = date.withDayOfMonth(date.lengthOfMonth())
|
||||
val records = recordDao.getRecordsInRangeOnce(monthStart.toEpochDay(), monthEnd.toEpochDay())
|
||||
for (record in records) {
|
||||
if (record.isPeriodStart && record.date != date.toEpochDay()) {
|
||||
recordDao.upsert(record.copy(isPeriodStart = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.pinkbear.pinkov.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Color constants for the fertility calendar day cells.
|
||||
*/
|
||||
object FertilityColors {
|
||||
/** Actual menstrual period — deep pink. */
|
||||
val actualPeriod = Color(0xFFE91E63)
|
||||
|
||||
/** Predicted menstrual period — light pink. */
|
||||
val predictedPeriod = Color(0xFFFFCDD2)
|
||||
|
||||
/** Ovulation / fertile window — soft purple. */
|
||||
val ovulation = Color(0xFFCE93D8)
|
||||
|
||||
/** Ovulation day hexagon marker — deep purple. */
|
||||
val ovulationDayMarker = Color(0xFF7B1FA2)
|
||||
|
||||
/** Today highlight border. */
|
||||
val todayBorder = Color(0xFF2196F3)
|
||||
|
||||
/** Selected date border. */
|
||||
val selectedBorder = Color(0xFF1976D2)
|
||||
|
||||
/** Text color on dark backgrounds (actual period). */
|
||||
val textOnDark = Color.White
|
||||
|
||||
/** Text color for adjacent-month days. */
|
||||
val adjacentMonthText = Color(0xFFBDBDBD)
|
||||
}
|
||||
Reference in New Issue
Block a user