Initial commit: Implement base health and cycle tracking application.

- Set up Android project structure with Gradle 8.7, Kotlin 2.0, and Hilt.
- Implement Room database with entities for Profiles, Day Logs, Conditions, Cycle Records, and Intimacy Logs.
- Integrate Jetpack Compose for the UI layer including Navigation and Material3.
- Develop a cycle prediction engine to calculate menstruation, fertile windows, and ovulation based on user history.
- Implement core screens:
    - **Onboarding:** Initial profile setup for female/male users.
    - **Calendar:** Monthly view showing cycle phases, logged symptoms, and intimacy records.
    - **Day Detail:** Detailed logging for symptoms (with ratings), period status, notes, and intimacy encounters.
    - **Cycle Insights:** Visualization of cycle phases and historical cycle length trends.
    - **Health Trends:** Frequency and recurrence analysis of logged health conditions over various time ranges.
    - **Settings:** Profile management, data clearing, and app theme configuration.
- Add DataStore for managing user preferences such as active profile and onboarding status.

Signed-off-by: whitlocktech <whitlocktech@gmail.com>
This commit is contained in:
2026-05-22 17:21:30 -05:00
commit b3bd69ab26
138 changed files with 8150 additions and 0 deletions

76
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,76 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.hsdiary"
compileSdk = 34
defaultConfig {
applicationId = "com.hsdiary"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.datastore.preferences)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
debugImplementation(libs.compose.ui.tooling)
}

2
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
-keep class com.hsdiary.** { *; }
-keepattributes *Annotation*

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".HSDiaryApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HSDiary">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,7 @@
package com.hsdiary
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class HSDiaryApplication : Application()

View File

@@ -0,0 +1,22 @@
package com.hsdiary
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.hsdiary.ui.navigation.AppNavigation
import com.hsdiary.ui.theme.HSDiaryTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
HSDiaryTheme {
AppNavigation()
}
}
}
}

View File

@@ -0,0 +1,133 @@
package com.hsdiary.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.hsdiary.data.db.dao.*
import com.hsdiary.data.db.entity.*
@Database(
entities = [
ProfileEntity::class,
DayLogEntity::class,
ConditionEntryEntity::class,
CycleRecordEntity::class,
IntimacyLogEntity::class,
ConditionDefinitionEntity::class
],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun profileDao(): ProfileDao
abstract fun dayLogDao(): DayLogDao
abstract fun conditionDao(): ConditionDao
abstract fun cycleRecordDao(): CycleRecordDao
abstract fun intimacyLogDao(): IntimacyLogDao
abstract fun conditionDefinitionDao(): ConditionDefinitionDao
companion object {
val seedCallback = object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
seedConditionDefinitions(db)
}
private fun seedConditionDefinitions(db: SupportSQLiteDatabase) {
data class Seed(val key: String, val name: String, val cat: String, val vis: String, val order: Int)
val seeds = listOf(
// Head & Neurological
Seed("HEADACHE", "Headache", "HEAD_NEUROLOGICAL", "ALL", 0),
Seed("MIGRAINE", "Migraine", "HEAD_NEUROLOGICAL", "ALL", 1),
Seed("BRAIN_FOG", "Brain fog", "HEAD_NEUROLOGICAL", "ALL", 2),
Seed("DIZZINESS", "Dizziness", "HEAD_NEUROLOGICAL", "ALL", 3),
Seed("FAINTING", "Fainting / near-fainting", "HEAD_NEUROLOGICAL", "ALL", 4),
Seed("TINNITUS", "Tinnitus", "HEAD_NEUROLOGICAL", "ALL", 5),
Seed("VISION_DISTURBANCE", "Vision disturbance", "HEAD_NEUROLOGICAL", "ALL", 6),
Seed("NUMBNESS_TINGLING", "Numbness / tingling", "HEAD_NEUROLOGICAL", "ALL", 7),
// Digestive
Seed("NAUSEA", "Nausea", "DIGESTIVE", "ALL", 0),
Seed("VOMITING", "Vomiting", "DIGESTIVE", "ALL", 1),
Seed("BLOATING", "Bloating", "DIGESTIVE", "ALL", 2),
Seed("CONSTIPATION", "Constipation", "DIGESTIVE", "ALL", 3),
Seed("DIARRHEA", "Diarrhea", "DIGESTIVE", "ALL", 4),
Seed("ACID_REFLUX", "Acid reflux / heartburn", "DIGESTIVE", "ALL", 5),
Seed("APPETITE_LOSS", "Appetite loss", "DIGESTIVE", "ALL", 6),
Seed("APPETITE_INCREASE", "Appetite increase", "DIGESTIVE", "ALL", 7),
Seed("ABDOMINAL_CRAMPING", "Abdominal cramping", "DIGESTIVE", "ALL", 8),
Seed("GAS", "Gas", "DIGESTIVE", "ALL", 9),
// Musculoskeletal
Seed("BACK_PAIN", "Back pain", "MUSCULOSKELETAL", "ALL", 0),
Seed("NECK_PAIN", "Neck pain", "MUSCULOSKELETAL", "ALL", 1),
Seed("JOINT_PAIN", "Joint pain", "MUSCULOSKELETAL", "ALL", 2),
Seed("MUSCLE_ACHES", "Muscle aches", "MUSCULOSKELETAL", "ALL", 3),
Seed("MUSCLE_CRAMPS", "Muscle cramps", "MUSCULOSKELETAL", "ALL", 4),
Seed("STIFFNESS", "Stiffness", "MUSCULOSKELETAL", "ALL", 5),
Seed("SWOLLEN_JOINTS", "Swelling in joints", "MUSCULOSKELETAL", "ALL", 6),
// Respiratory
Seed("SHORTNESS_OF_BREATH", "Shortness of breath", "RESPIRATORY", "ALL", 0),
Seed("CHEST_TIGHTNESS", "Chest tightness", "RESPIRATORY", "ALL", 1),
Seed("COUGH", "Cough", "RESPIRATORY", "ALL", 2),
Seed("CONGESTION", "Congestion", "RESPIRATORY", "ALL", 3),
Seed("SORE_THROAT", "Sore throat", "RESPIRATORY", "ALL", 4),
Seed("WHEEZING", "Wheezing", "RESPIRATORY", "ALL", 5),
// Cardiovascular
Seed("HEART_PALPITATIONS", "Heart palpitations", "CARDIOVASCULAR", "ALL", 0),
Seed("CHEST_PAIN", "Chest pain", "CARDIOVASCULAR", "ALL", 1),
Seed("RAPID_HEARTBEAT", "Rapid heartbeat", "CARDIOVASCULAR", "ALL", 2),
Seed("LOW_BP_SYMPTOMS", "Low blood pressure symptoms", "CARDIOVASCULAR", "ALL", 3),
Seed("SWOLLEN_ANKLES", "Swollen ankles / feet", "CARDIOVASCULAR", "ALL", 4),
// Energy & Sleep
Seed("FATIGUE", "Fatigue", "ENERGY_SLEEP", "ALL", 0),
Seed("EXHAUSTION", "Exhaustion", "ENERGY_SLEEP", "ALL", 1),
Seed("INSOMNIA", "Insomnia", "ENERGY_SLEEP", "ALL", 2),
Seed("HYPERSOMNIA", "Hypersomnia (sleeping too much)", "ENERGY_SLEEP", "ALL", 3),
Seed("RESTLESS_SLEEP", "Restless sleep", "ENERGY_SLEEP", "ALL", 4),
Seed("NIGHT_SWEATS", "Night sweats", "ENERGY_SLEEP", "ALL", 5),
// Mood & Mental
Seed("ANXIOUS", "Anxious", "MOOD_MENTAL", "ALL", 0),
Seed("IRRITABLE", "Irritable", "MOOD_MENTAL", "ALL", 1),
Seed("DEPRESSED", "Depressed", "MOOD_MENTAL", "ALL", 2),
Seed("MOOD_SWINGS", "Mood swings", "MOOD_MENTAL", "ALL", 3),
Seed("OVERWHELMED", "Overwhelmed", "MOOD_MENTAL", "ALL", 4),
Seed("CALM", "Calm", "MOOD_MENTAL", "ALL", 5),
Seed("HAPPY", "Happy", "MOOD_MENTAL", "ALL", 6),
Seed("PANIC_ATTACK", "Panic attack", "MOOD_MENTAL", "ALL", 7),
Seed("LOW_MOTIVATION", "Low motivation", "MOOD_MENTAL", "ALL", 8),
// Skin
Seed("RASH", "Rash", "SKIN", "ALL", 0),
Seed("HIVES", "Hives", "SKIN", "ALL", 1),
Seed("ACNE", "Acne breakout", "SKIN", "ALL", 2),
Seed("DRY_SKIN", "Dry skin", "SKIN", "ALL", 3),
Seed("EXCESSIVE_SWEATING", "Excessive sweating", "SKIN", "ALL", 4),
Seed("ITCHING", "Itching", "SKIN", "ALL", 5),
Seed("BRUISING", "Bruising easily", "SKIN", "ALL", 6),
// Female-Specific
Seed("CRAMPS", "Cramps", "FEMALE_SPECIFIC", "FEMALE_ONLY", 0),
Seed("BREAST_TENDERNESS", "Breast tenderness", "FEMALE_SPECIFIC", "FEMALE_ONLY", 1),
Seed("SPOTTING", "Spotting", "FEMALE_SPECIFIC", "FEMALE_ONLY", 2),
Seed("DISCHARGE_NORMAL", "Discharge — normal", "FEMALE_SPECIFIC", "FEMALE_ONLY", 3),
Seed("DISCHARGE_UNUSUAL", "Discharge — unusual", "FEMALE_SPECIFIC", "FEMALE_ONLY", 4),
Seed("HOT_FLASHES", "Hot flashes", "FEMALE_SPECIFIC", "FEMALE_ONLY", 5),
Seed("PMS", "PMS symptoms", "FEMALE_SPECIFIC", "FEMALE_ONLY", 6),
Seed("OVULATION_PAIN", "Ovulation pain (Mittelschmerz)", "FEMALE_SPECIFIC", "FEMALE_ONLY", 7),
// General
Seed("FEVER", "Fever", "GENERAL", "ALL", 0),
Seed("CHILLS", "Chills", "GENERAL", "ALL", 1),
Seed("DEHYDRATION", "Dehydration", "GENERAL", "ALL", 2),
Seed("ALLERGIC_REACTION", "Allergic reaction", "GENERAL", "ALL", 3),
Seed("COLD_FLU", "Cold / flu symptoms", "GENERAL", "ALL", 4),
Seed("SWOLLEN_LYMPH", "Swollen lymph nodes", "GENERAL", "ALL", 5)
)
seeds.forEach { s ->
val name = s.name.replace("'", "''")
db.execSQL(
"INSERT INTO condition_definitions (condition_key, display_name, category, profile_visibility, sort_order) " +
"VALUES ('${s.key}', '$name', '${s.cat}', '${s.vis}', ${s.order})"
)
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.hsdiary.data.db.dao
import androidx.room.*
import com.hsdiary.data.db.entity.ConditionEntryEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ConditionDao {
@Query("SELECT * FROM conditions WHERE day_log_id = :dayLogId")
fun getConditionsForDay(dayLogId: Long): Flow<List<ConditionEntryEntity>>
@Query("SELECT * FROM conditions WHERE day_log_id = :dayLogId")
suspend fun getConditionsForDayOnce(dayLogId: Long): List<ConditionEntryEntity>
@Query("""
SELECT c.* FROM conditions c
JOIN day_logs dl ON c.day_log_id = dl.id
WHERE dl.profile_id = :profileId AND dl.date >= :startDate AND dl.date <= :endDate
""")
suspend fun getConditionsInRange(profileId: Long, startDate: String, endDate: String): List<ConditionEntryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCondition(condition: ConditionEntryEntity): Long
@Delete
suspend fun deleteCondition(condition: ConditionEntryEntity)
@Query("DELETE FROM conditions WHERE day_log_id = :dayLogId AND condition_key = :conditionKey")
suspend fun deleteConditionByKey(dayLogId: Long, conditionKey: String)
@Query("DELETE FROM conditions WHERE day_log_id = :dayLogId")
suspend fun deleteAllForDayLog(dayLogId: Long)
}

View File

@@ -0,0 +1,23 @@
package com.hsdiary.data.db.dao
import androidx.room.*
import com.hsdiary.data.db.entity.ConditionDefinitionEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ConditionDefinitionDao {
@Query("SELECT * FROM condition_definitions ORDER BY category, sort_order")
fun getAllDefinitions(): Flow<List<ConditionDefinitionEntity>>
@Query("SELECT * FROM condition_definitions ORDER BY category, sort_order")
suspend fun getAllDefinitionsOnce(): List<ConditionDefinitionEntity>
@Query("SELECT * FROM condition_definitions WHERE profile_visibility = 'ALL' OR profile_visibility = :visibility ORDER BY category, sort_order")
suspend fun getDefinitionsForProfile(visibility: String): List<ConditionDefinitionEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(definitions: List<ConditionDefinitionEntity>)
@Query("SELECT COUNT(*) FROM condition_definitions")
suspend fun getCount(): Int
}

View File

@@ -0,0 +1,41 @@
package com.hsdiary.data.db.dao
import androidx.room.*
import com.hsdiary.data.db.entity.CycleRecordEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CycleRecordDao {
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId ORDER BY cycle_start ASC")
fun getCycleRecords(profileId: Long): Flow<List<CycleRecordEntity>>
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId ORDER BY cycle_start ASC")
suspend fun getCycleRecordsOnce(profileId: Long): List<CycleRecordEntity>
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId ORDER BY cycle_start DESC LIMIT 1")
suspend fun getLatestCycleRecord(profileId: Long): CycleRecordEntity?
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId AND cycle_end IS NULL ORDER BY cycle_start DESC LIMIT 1")
suspend fun getCurrentCycleRecord(profileId: Long): CycleRecordEntity?
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId AND cycle_start = :date LIMIT 1")
suspend fun getByStartDate(profileId: Long, date: String): CycleRecordEntity?
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId AND cycle_end = :date LIMIT 1")
suspend fun getByEndDate(profileId: Long, date: String): CycleRecordEntity?
@Query("SELECT * FROM cycle_records WHERE profile_id = :profileId AND cycle_start <= :date AND (cycle_end IS NULL OR cycle_end >= :date) LIMIT 1")
suspend fun getRecordContainingDate(profileId: Long, date: String): CycleRecordEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCycleRecord(record: CycleRecordEntity): Long
@Update
suspend fun updateCycleRecord(record: CycleRecordEntity)
@Delete
suspend fun deleteCycleRecord(record: CycleRecordEntity)
@Query("DELETE FROM cycle_records WHERE profile_id = :profileId")
suspend fun deleteAllForProfile(profileId: Long)
}

View File

@@ -0,0 +1,32 @@
package com.hsdiary.data.db.dao
import androidx.room.*
import com.hsdiary.data.db.entity.DayLogEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface DayLogDao {
@Query("SELECT * FROM day_logs WHERE profile_id = :profileId AND date = :date")
suspend fun getDayLog(profileId: Long, date: String): DayLogEntity?
@Query("SELECT * FROM day_logs WHERE profile_id = :profileId AND date = :date")
fun getDayLogFlow(profileId: Long, date: String): Flow<DayLogEntity?>
@Query("SELECT * FROM day_logs WHERE profile_id = :profileId AND date >= :startDate AND date <= :endDate ORDER BY date ASC")
fun getDayLogsInRange(profileId: Long, startDate: String, endDate: String): Flow<List<DayLogEntity>>
@Query("SELECT * FROM day_logs WHERE profile_id = :profileId AND date >= :startDate AND date <= :endDate ORDER BY date ASC")
suspend fun getDayLogsInRangeOnce(profileId: Long, startDate: String, endDate: String): List<DayLogEntity>
@Query("SELECT * FROM day_logs WHERE profile_id = :profileId AND period_active = 1 ORDER BY date ASC")
suspend fun getPeriodDays(profileId: Long): List<DayLogEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDayLog(dayLog: DayLogEntity): Long
@Update
suspend fun updateDayLog(dayLog: DayLogEntity)
@Query("DELETE FROM day_logs WHERE profile_id = :profileId")
suspend fun deleteAllForProfile(profileId: Long)
}

View File

@@ -0,0 +1,32 @@
package com.hsdiary.data.db.dao
import androidx.room.*
import com.hsdiary.data.db.entity.IntimacyLogEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface IntimacyLogDao {
@Query("SELECT * FROM intimacy_logs WHERE date = :date AND owner_profile_id = :profileId ORDER BY rowid ASC")
fun getLogsForDay(date: String, profileId: Long): Flow<List<IntimacyLogEntity>>
@Query("SELECT * FROM intimacy_logs WHERE date = :date AND owner_profile_id = :profileId ORDER BY rowid ASC")
suspend fun getLogsForDayOnce(date: String, profileId: Long): List<IntimacyLogEntity>
@Query("SELECT DISTINCT date FROM intimacy_logs WHERE owner_profile_id = :profileId AND date >= :startDate AND date <= :endDate")
suspend fun getDatesWithLogs(profileId: Long, startDate: String, endDate: String): List<String>
@Query("SELECT * FROM intimacy_logs WHERE date >= :startDate AND date <= :endDate AND owner_profile_id = :profileId ORDER BY date ASC")
suspend fun getLogsInRange(profileId: Long, startDate: String, endDate: String): List<IntimacyLogEntity>
@Insert
suspend fun insertLog(log: IntimacyLogEntity): Long
@Update
suspend fun updateLog(log: IntimacyLogEntity)
@Delete
suspend fun deleteLog(log: IntimacyLogEntity)
@Query("DELETE FROM intimacy_logs WHERE owner_profile_id = :profileId")
suspend fun deleteAllForProfile(profileId: Long)
}

View File

@@ -0,0 +1,29 @@
package com.hsdiary.data.db.dao
import androidx.room.*
import com.hsdiary.data.db.entity.ProfileEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ProfileDao {
@Query("SELECT * FROM profiles ORDER BY created_at ASC")
fun getAllProfiles(): Flow<List<ProfileEntity>>
@Query("SELECT * FROM profiles ORDER BY created_at ASC")
suspend fun getAllProfilesOnce(): List<ProfileEntity>
@Query("SELECT * FROM profiles WHERE id = :id")
suspend fun getProfileById(id: Long): ProfileEntity?
@Query("SELECT COUNT(*) FROM profiles")
suspend fun getProfileCount(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProfile(profile: ProfileEntity): Long
@Update
suspend fun updateProfile(profile: ProfileEntity)
@Delete
suspend fun deleteProfile(profile: ProfileEntity)
}

View File

@@ -0,0 +1,15 @@
package com.hsdiary.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "condition_definitions")
data class ConditionDefinitionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "condition_key") val conditionKey: String,
@ColumnInfo(name = "display_name") val displayName: String,
val category: String,
@ColumnInfo(name = "profile_visibility") val profileVisibility: String,
@ColumnInfo(name = "sort_order") val sortOrder: Int
)

View File

@@ -0,0 +1,24 @@
package com.hsdiary.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "conditions",
foreignKeys = [ForeignKey(
entity = DayLogEntity::class,
parentColumns = ["id"],
childColumns = ["day_log_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("day_log_id")]
)
data class ConditionEntryEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "day_log_id") val dayLogId: Long,
@ColumnInfo(name = "condition_key") val conditionKey: String,
val rating: Int = 3
)

View File

@@ -0,0 +1,27 @@
package com.hsdiary.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "cycle_records",
foreignKeys = [ForeignKey(
entity = ProfileEntity::class,
parentColumns = ["id"],
childColumns = ["profile_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("profile_id")]
)
data class CycleRecordEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "profile_id") val profileId: Long,
@ColumnInfo(name = "cycle_start") val cycleStart: String,
@ColumnInfo(name = "cycle_end") val cycleEnd: String? = null,
@ColumnInfo(name = "cycle_length") val cycleLength: Int? = null,
@ColumnInfo(name = "predicted_start") val predictedStart: String? = null,
@ColumnInfo(name = "delta_days") val deltaDays: Int? = null
)

View File

@@ -0,0 +1,25 @@
package com.hsdiary.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "day_logs",
foreignKeys = [ForeignKey(
entity = ProfileEntity::class,
parentColumns = ["id"],
childColumns = ["profile_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("profile_id"), Index(value = ["profile_id", "date"], unique = true)]
)
data class DayLogEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "profile_id") val profileId: Long,
val date: String,
@ColumnInfo(name = "period_active") val periodActive: Boolean = false,
val notes: String? = null
)

View File

@@ -0,0 +1,28 @@
package com.hsdiary.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "intimacy_logs",
foreignKeys = [ForeignKey(
entity = ProfileEntity::class,
parentColumns = ["id"],
childColumns = ["owner_profile_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("owner_profile_id")]
)
data class IntimacyLogEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val date: String,
@ColumnInfo(name = "owner_profile_id") val ownerProfileId: Long,
@ColumnInfo(name = "participant_type") val participantType: String,
@ColumnInfo(name = "participant_name") val participantName: String? = null,
@ColumnInfo(name = "time_of_day") val timeOfDay: String? = null,
val protected: Boolean = true,
val shared: Boolean = true
)

View File

@@ -0,0 +1,17 @@
package com.hsdiary.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "profiles")
data class ProfileEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
@ColumnInfo(name = "avatar_color") val avatarColor: String,
@ColumnInfo(name = "profile_type") val profileType: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "cycle_length_default") val cycleLengthDefault: Int = 28,
@ColumnInfo(name = "reproductive_status") val reproductiveStatus: String = "{}"
)

View File

@@ -0,0 +1,14 @@
package com.hsdiary.data.model
enum class ConditionCategory(val displayName: String) {
HEAD_NEUROLOGICAL("Head & Neurological"),
DIGESTIVE("Digestive"),
MUSCULOSKELETAL("Musculoskeletal"),
RESPIRATORY("Respiratory"),
CARDIOVASCULAR("Cardiovascular"),
ENERGY_SLEEP("Energy & Sleep"),
MOOD_MENTAL("Mood & Mental"),
SKIN("Skin"),
FEMALE_SPECIFIC("Female-Specific"),
GENERAL("General")
}

View File

@@ -0,0 +1,13 @@
package com.hsdiary.data.model
enum class CyclePhase {
MENSTRUATION_CONFIRMED,
MENSTRUATION_PREDICTED,
FERTILE_WINDOW,
FERTILE_WINDOW_PREDICTED,
OVULATION,
OVULATION_PREDICTED,
LUTEAL,
FOLLICULAR,
NO_DATA
}

View File

@@ -0,0 +1,3 @@
package com.hsdiary.data.model
enum class ProfileType { FEMALE, MALE }

View File

@@ -0,0 +1,30 @@
package com.hsdiary.data.model
import kotlinx.serialization.Serializable
enum class FemaleReproductiveStatus(val label: String) {
NORMAL("Normal / no known factors"),
HORMONAL_BC("Hormonal birth control"),
IUD_HORMONAL("IUD — hormonal"),
IUD_COPPER("IUD — copper"),
TUBAL_LIGATION("Tubal ligation"),
TRYING_TO_CONCEIVE("Trying to conceive"),
PREGNANT("Pregnant"),
POSTPARTUM("Postpartum"),
IRREGULAR("Irregular / perimenopause"),
OTHER("Other / prefer not to say")
}
enum class MaleReproductiveStatus(val label: String) {
NORMAL("Normal / no known factors"),
VASECTOMY_CONFIRMED("Vasectomy — confirmed"),
VASECTOMY_PENDING("Vasectomy — awaiting confirmation"),
OTHER("Other / prefer not to say")
}
@Serializable
data class ReproductiveStatusData(
val femaleStatus: String = FemaleReproductiveStatus.NORMAL.name,
val maleStatus: String = MaleReproductiveStatus.NORMAL.name,
val optionalDate: String? = null
)

View File

@@ -0,0 +1,57 @@
package com.hsdiary.data.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "hs_diary_prefs")
@Singleton
class UserPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private object Keys {
val ACTIVE_PROFILE_ID = longPreferencesKey("active_profile_id")
val FIRST_DAY_OF_WEEK = intPreferencesKey("first_day_of_week") // 1=Sunday, 2=Monday
val APP_THEME = stringPreferencesKey("app_theme") // LIGHT, DARK, SYSTEM
val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
}
val activeProfileId: Flow<Long?> = context.dataStore.data.map { prefs ->
prefs[Keys.ACTIVE_PROFILE_ID]
}
val firstDayOfWeek: Flow<Int> = context.dataStore.data.map { prefs ->
prefs[Keys.FIRST_DAY_OF_WEEK] ?: 1
}
val appTheme: Flow<String> = context.dataStore.data.map { prefs ->
prefs[Keys.APP_THEME] ?: "SYSTEM"
}
val onboardingComplete: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.ONBOARDING_COMPLETE] ?: false
}
suspend fun setActiveProfileId(id: Long) {
context.dataStore.edit { prefs -> prefs[Keys.ACTIVE_PROFILE_ID] = id }
}
suspend fun setFirstDayOfWeek(day: Int) {
context.dataStore.edit { prefs -> prefs[Keys.FIRST_DAY_OF_WEEK] = day }
}
suspend fun setAppTheme(theme: String) {
context.dataStore.edit { prefs -> prefs[Keys.APP_THEME] = theme }
}
suspend fun setOnboardingComplete(complete: Boolean) {
context.dataStore.edit { prefs -> prefs[Keys.ONBOARDING_COMPLETE] = complete }
}
}

View File

@@ -0,0 +1,69 @@
package com.hsdiary.data.repository
import com.hsdiary.data.db.dao.CycleRecordDao
import com.hsdiary.data.db.entity.CycleRecordEntity
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CycleRepository @Inject constructor(private val dao: CycleRecordDao) {
fun getCycleRecords(profileId: Long): Flow<List<CycleRecordEntity>> =
dao.getCycleRecords(profileId)
suspend fun getCycleRecordsOnce(profileId: Long): List<CycleRecordEntity> =
dao.getCycleRecordsOnce(profileId)
suspend fun startNewCycle(profileId: Long, startDate: String, predictedStart: String?) {
val existing = dao.getCurrentCycleRecord(profileId)
if (existing != null) {
val length = java.time.LocalDate.parse(startDate)
.toEpochDay()
.minus(java.time.LocalDate.parse(existing.cycleStart).toEpochDay())
.toInt()
val delta = predictedStart?.let {
java.time.LocalDate.parse(startDate)
.toEpochDay()
.minus(java.time.LocalDate.parse(it).toEpochDay())
.toInt()
}
dao.updateCycleRecord(existing.copy(cycleEnd = startDate, cycleLength = length, deltaDays = delta))
}
dao.insertCycleRecord(CycleRecordEntity(
profileId = profileId,
cycleStart = startDate,
predictedStart = predictedStart
))
}
suspend fun endCurrentCycle(profileId: Long, endDate: String) {
val current = dao.getCurrentCycleRecord(profileId) ?: return
val length = java.time.LocalDate.parse(endDate)
.toEpochDay()
.minus(java.time.LocalDate.parse(current.cycleStart).toEpochDay())
.toInt() + 1
dao.updateCycleRecord(current.copy(cycleEnd = endDate, cycleLength = length))
}
suspend fun removePeriodStart(profileId: Long, date: String) {
val record = dao.getByStartDate(profileId, date) ?: return
dao.deleteCycleRecord(record)
}
suspend fun removePeriodEnd(profileId: Long, date: String) {
val record = dao.getByEndDate(profileId, date) ?: return
dao.updateCycleRecord(record.copy(cycleEnd = null, cycleLength = null))
}
suspend fun isPeriodStart(profileId: Long, date: String): Boolean =
dao.getByStartDate(profileId, date) != null
suspend fun isPeriodEnd(profileId: Long, date: String): Boolean =
dao.getByEndDate(profileId, date) != null
suspend fun getRecordContainingDate(profileId: Long, date: String): CycleRecordEntity? =
dao.getRecordContainingDate(profileId, date)
suspend fun deleteAllForProfile(profileId: Long) = dao.deleteAllForProfile(profileId)
}

View File

@@ -0,0 +1,67 @@
package com.hsdiary.data.repository
import com.hsdiary.data.db.dao.ConditionDao
import com.hsdiary.data.db.dao.ConditionDefinitionDao
import com.hsdiary.data.db.dao.DayLogDao
import com.hsdiary.data.db.entity.ConditionEntryEntity
import com.hsdiary.data.db.entity.DayLogEntity
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DayLogRepository @Inject constructor(
private val dayLogDao: DayLogDao,
private val conditionDao: ConditionDao,
private val conditionDefinitionDao: ConditionDefinitionDao
) {
fun getDayLogFlow(profileId: Long, date: String): Flow<DayLogEntity?> =
dayLogDao.getDayLogFlow(profileId, date)
fun getDayLogsInRange(profileId: Long, startDate: String, endDate: String): Flow<List<DayLogEntity>> =
dayLogDao.getDayLogsInRange(profileId, startDate, endDate)
suspend fun getDayLogsInRangeOnce(profileId: Long, startDate: String, endDate: String): List<DayLogEntity> =
dayLogDao.getDayLogsInRangeOnce(profileId, startDate, endDate)
suspend fun getOrCreateDayLog(profileId: Long, date: String): DayLogEntity {
val existing = dayLogDao.getDayLog(profileId, date)
if (existing != null) return existing
val newLog = DayLogEntity(profileId = profileId, date = date)
val id = dayLogDao.insertDayLog(newLog)
return newLog.copy(id = id)
}
suspend fun upsertDayLog(dayLog: DayLogEntity): Long {
return dayLogDao.insertDayLog(dayLog)
}
suspend fun updateDayLog(dayLog: DayLogEntity) = dayLogDao.updateDayLog(dayLog)
fun getConditionsForDay(dayLogId: Long): Flow<List<ConditionEntryEntity>> =
conditionDao.getConditionsForDay(dayLogId)
suspend fun getConditionsForDayOnce(dayLogId: Long): List<ConditionEntryEntity> =
conditionDao.getConditionsForDayOnce(dayLogId)
suspend fun setCondition(dayLogId: Long, conditionKey: String, rating: Int) {
conditionDao.deleteConditionByKey(dayLogId, conditionKey)
conditionDao.insertCondition(ConditionEntryEntity(dayLogId = dayLogId, conditionKey = conditionKey, rating = rating))
}
suspend fun removeCondition(dayLogId: Long, conditionKey: String) {
conditionDao.deleteConditionByKey(dayLogId, conditionKey)
}
suspend fun getConditionsInRange(profileId: Long, startDate: String, endDate: String) =
conditionDao.getConditionsInRange(profileId, startDate, endDate)
suspend fun getDefinitionsForProfile(isFemale: Boolean) =
conditionDefinitionDao.getDefinitionsForProfile(if (isFemale) "FEMALE_ONLY" else "NONE")
suspend fun getAllDefinitions() = conditionDefinitionDao.getAllDefinitionsOnce()
suspend fun getPeriodDays(profileId: Long) = dayLogDao.getPeriodDays(profileId)
suspend fun deleteAllForProfile(profileId: Long) = dayLogDao.deleteAllForProfile(profileId)
}

View File

@@ -0,0 +1,28 @@
package com.hsdiary.data.repository
import com.hsdiary.data.db.dao.IntimacyLogDao
import com.hsdiary.data.db.entity.IntimacyLogEntity
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IntimacyRepository @Inject constructor(private val dao: IntimacyLogDao) {
fun getLogsForDay(date: String, profileId: Long): Flow<List<IntimacyLogEntity>> =
dao.getLogsForDay(date, profileId)
suspend fun getLogsForDayOnce(date: String, profileId: Long) =
dao.getLogsForDayOnce(date, profileId)
suspend fun getDatesWithLogs(profileId: Long, startDate: String, endDate: String): List<String> =
dao.getDatesWithLogs(profileId, startDate, endDate)
suspend fun insertLog(log: IntimacyLogEntity): Long = dao.insertLog(log)
suspend fun updateLog(log: IntimacyLogEntity) = dao.updateLog(log)
suspend fun deleteLog(log: IntimacyLogEntity) = dao.deleteLog(log)
suspend fun deleteAllForProfile(profileId: Long) = dao.deleteAllForProfile(profileId)
}

View File

@@ -0,0 +1,25 @@
package com.hsdiary.data.repository
import com.hsdiary.data.db.dao.ProfileDao
import com.hsdiary.data.db.entity.ProfileEntity
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProfileRepository @Inject constructor(private val dao: ProfileDao) {
fun getAllProfiles(): Flow<List<ProfileEntity>> = dao.getAllProfiles()
suspend fun getAllProfilesOnce(): List<ProfileEntity> = dao.getAllProfilesOnce()
suspend fun getProfileById(id: Long): ProfileEntity? = dao.getProfileById(id)
suspend fun insertProfile(profile: ProfileEntity): Long = dao.insertProfile(profile)
suspend fun updateProfile(profile: ProfileEntity) = dao.updateProfile(profile)
suspend fun deleteProfile(profile: ProfileEntity) = dao.deleteProfile(profile)
suspend fun getProfileCount(): Int = dao.getProfileCount()
}

View File

@@ -0,0 +1,24 @@
package com.hsdiary.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob())
}

View File

@@ -0,0 +1,32 @@
package com.hsdiary.di
import android.content.Context
import androidx.room.Room
import com.hsdiary.data.db.AppDatabase
import com.hsdiary.data.db.dao.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "hs_diary.db")
.addCallback(AppDatabase.seedCallback)
.build()
}
@Provides fun provideProfileDao(db: AppDatabase): ProfileDao = db.profileDao()
@Provides fun provideDayLogDao(db: AppDatabase): DayLogDao = db.dayLogDao()
@Provides fun provideConditionDao(db: AppDatabase): ConditionDao = db.conditionDao()
@Provides fun provideCycleRecordDao(db: AppDatabase): CycleRecordDao = db.cycleRecordDao()
@Provides fun provideIntimacyLogDao(db: AppDatabase): IntimacyLogDao = db.intimacyLogDao()
@Provides fun provideConditionDefinitionDao(db: AppDatabase): ConditionDefinitionDao = db.conditionDefinitionDao()
}

View File

@@ -0,0 +1,159 @@
package com.hsdiary.domain
import com.hsdiary.data.db.entity.CycleRecordEntity
import com.hsdiary.data.model.CyclePhase
import com.hsdiary.domain.model.CyclePrediction
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
@Singleton
class CyclePredictionEngine @Inject constructor() {
fun buildPrediction(
records: List<CycleRecordEntity>,
defaultCycleLength: Int,
today: LocalDate = LocalDate.now(),
rangeStart: LocalDate = today.minusMonths(1),
rangeEnd: LocalDate = today.plusMonths(2)
): CyclePrediction {
if (records.isEmpty()) return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd)
val completedLengths = records.mapNotNull { it.cycleLength }
val avgLength = if (completedLengths.isEmpty()) defaultCycleLength
else completedLengths.takeLast(12).average().roundToInt()
val tier = when {
completedLengths.size >= 12 -> 4
completedLengths.size >= 4 -> 3
records.isNotEmpty() -> 2
else -> 1
}
val latestStart = records.maxByOrNull { it.cycleStart }?.let { LocalDate.parse(it.cycleStart) }
?: return buildDefaultPrediction(defaultCycleLength, today, rangeStart, rangeEnd)
val cycleDay = (today.toEpochDay() - latestStart.toEpochDay()).toInt() + 1
val nextPeriod = latestStart.plusDays(avgLength.toLong())
val lutealLength = 14
val ovulation = latestStart.plusDays((avgLength - lutealLength).toLong())
val fertileStart = ovulation.minusDays(5)
val fertileEnd = ovulation.plusDays(1)
val phaseMap = buildPhaseMap(
records = records,
latestStart = latestStart,
avgLength = avgLength,
ovulation = ovulation,
fertileStart = fertileStart,
fertileEnd = fertileEnd,
rangeStart = rangeStart,
rangeEnd = rangeEnd
)
val currentPhase = phaseMap[today] ?: CyclePhase.NO_DATA
return CyclePrediction(
currentCycleStartDate = latestStart,
currentCycleDay = maxOf(1, cycleDay),
currentPhase = currentPhase,
nextPeriodDate = nextPeriod,
fertileWindowStart = fertileStart,
fertileWindowEnd = fertileEnd,
ovulationDate = ovulation,
averageCycleLength = avgLength,
cyclesLogged = records.size,
tier = tier,
daysUntilNextPeriod = (nextPeriod.toEpochDay() - today.toEpochDay()).toInt().takeIf { it >= 0 },
phaseMap = phaseMap
)
}
private fun buildDefaultPrediction(
defaultLength: Int,
today: LocalDate,
rangeStart: LocalDate,
rangeEnd: LocalDate
): CyclePrediction {
return CyclePrediction(
currentCycleStartDate = null,
currentCycleDay = 0,
currentPhase = CyclePhase.NO_DATA,
nextPeriodDate = null,
fertileWindowStart = null,
fertileWindowEnd = null,
ovulationDate = null,
averageCycleLength = defaultLength,
cyclesLogged = 0,
tier = 1,
daysUntilNextPeriod = null,
phaseMap = emptyMap()
)
}
private fun buildPhaseMap(
records: List<CycleRecordEntity>,
latestStart: LocalDate,
avgLength: Int,
ovulation: LocalDate,
fertileStart: LocalDate,
fertileEnd: LocalDate,
rangeStart: LocalDate,
rangeEnd: LocalDate
): Map<LocalDate, CyclePhase> {
val map = mutableMapOf<LocalDate, CyclePhase>()
// Mark confirmed period days from actual records
records.forEach { record ->
val start = LocalDate.parse(record.cycleStart)
val end = record.cycleEnd?.let { LocalDate.parse(it) } ?: start.plusDays(4)
var d = start
while (!d.isAfter(end) && !d.isAfter(rangeEnd)) {
if (!d.isBefore(rangeStart)) map[d] = CyclePhase.MENSTRUATION_CONFIRMED
d = d.plusDays(1)
}
}
// Project forward from latestStart through rangeEnd in cycle increments
var cycleStart = latestStart
while (cycleStart.isBefore(rangeEnd)) {
val cycleOvulation = cycleStart.plusDays((avgLength - 14).toLong())
val cycleFertileStart = cycleOvulation.minusDays(5)
val cycleFertileEnd = cycleOvulation.plusDays(1)
val cycleEnd = cycleStart.plusDays(avgLength.toLong())
val periodEnd = cycleStart.plusDays(4)
// Period days (predicted if future, already marked confirmed if past)
var d = cycleStart
while (!d.isAfter(periodEnd) && !d.isAfter(rangeEnd)) {
if (!d.isBefore(rangeStart) && map[d] == null) {
map[d] = CyclePhase.MENSTRUATION_PREDICTED
}
d = d.plusDays(1)
}
// Fertile window
d = cycleFertileStart
while (!d.isAfter(cycleFertileEnd) && !d.isAfter(rangeEnd)) {
if (!d.isBefore(rangeStart) && map[d] == null) {
map[d] = if (d == cycleOvulation) CyclePhase.OVULATION_PREDICTED else CyclePhase.FERTILE_WINDOW_PREDICTED
}
d = d.plusDays(1)
}
// Luteal phase
d = cycleFertileEnd.plusDays(1)
while (!d.isBefore(cycleStart) && !d.isAfter(cycleEnd.minusDays(1)) && !d.isAfter(rangeEnd)) {
if (!d.isBefore(rangeStart) && map[d] == null) {
map[d] = CyclePhase.LUTEAL
}
d = d.plusDays(1)
}
cycleStart = cycleEnd
}
return map
}
}

View File

@@ -0,0 +1,24 @@
package com.hsdiary.domain.model
import com.hsdiary.data.model.CyclePhase
import java.time.LocalDate
data class CyclePrediction(
val currentCycleStartDate: LocalDate?,
val currentCycleDay: Int,
val currentPhase: CyclePhase,
val nextPeriodDate: LocalDate?,
val fertileWindowStart: LocalDate?,
val fertileWindowEnd: LocalDate?,
val ovulationDate: LocalDate?,
val averageCycleLength: Int,
val cyclesLogged: Int,
val tier: Int,
val daysUntilNextPeriod: Int?,
val phaseMap: Map<LocalDate, CyclePhase>
)
data class DayPhaseInfo(
val phase: CyclePhase,
val cycleDay: Int?
)

View File

@@ -0,0 +1,330 @@
package com.hsdiary.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.hsdiary.data.model.CyclePhase
import com.hsdiary.data.model.ProfileType
import com.hsdiary.ui.components.AvatarDot
import com.hsdiary.ui.components.ProfileSwitchSheet
import com.hsdiary.ui.theme.*
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.TextStyle
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarScreen(
onDayClick: (String) -> Unit,
onInsightsClick: () -> Unit,
onTrendsClick: () -> Unit,
onSettingsClick: () -> Unit,
viewModel: CalendarViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val activeProfile = state.activeProfile
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("H&S Diary", style = MaterialTheme.typography.titleLarge)
if (activeProfile != null) {
Text(
activeProfile.name,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
actions = {
IconButton(onClick = onTrendsClick) {
Icon(Icons.AutoMirrored.Filled.TrendingUp, contentDescription = "Trends")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
if (activeProfile != null) {
val avatarColor = parseColor(activeProfile.avatarColor)
IconButton(onClick = { viewModel.showProfileSheet() }) {
AvatarDot(name = activeProfile.name, avatarColor = avatarColor, size = 32)
}
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Context banner (female only)
if (activeProfile?.profileType == ProfileType.FEMALE.name) {
val prediction = state.prediction
ContextBanner(
prediction = prediction,
onClick = onInsightsClick
)
}
// Month navigation
MonthHeader(
month = state.currentMonth,
onPrevious = viewModel::previousMonth,
onNext = viewModel::nextMonth
)
// Day-of-week headers
val dayHeaders = if (state.firstDayOfWeek == 1)
listOf("S","M","T","W","T","F","S")
else
listOf("M","T","W","T","F","S","S")
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
dayHeaders.forEach { h ->
Text(
text = h,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Calendar grid
val isFemale = activeProfile?.profileType == ProfileType.FEMALE.name
CalendarGrid(
days = state.dayStates,
isFemale = isFemale,
onDayClick = { onDayClick(it.toString()) }
)
}
}
if (state.showProfileSheet && state.profiles.size > 1) {
ProfileSwitchSheet(
profiles = viewModel.getProfileSwitchItems(state),
onProfileSelected = viewModel::switchProfile,
onDismiss = viewModel::hideProfileSheet
)
}
}
@Composable
private fun ContextBanner(
prediction: com.hsdiary.domain.model.CyclePrediction?,
onClick: () -> Unit
) {
val text = when {
prediction == null || prediction.cyclesLogged == 0 ->
"🩸 Set up your cycle — log your first period to begin"
prediction.currentPhase == CyclePhase.MENSTRUATION_CONFIRMED ||
prediction.currentPhase == CyclePhase.MENSTRUATION_PREDICTED ->
"🩸 Period · Day ${prediction.currentCycleDay}"
prediction.currentPhase == CyclePhase.OVULATION ||
prediction.currentPhase == CyclePhase.OVULATION_PREDICTED ->
"🌿 Ovulation day"
prediction.currentPhase == CyclePhase.FERTILE_WINDOW ||
prediction.currentPhase == CyclePhase.FERTILE_WINDOW_PREDICTED -> {
val remaining = prediction.fertileWindowEnd?.let {
(it.toEpochDay() - LocalDate.now().toEpochDay()).toInt()
} ?: 0
"🌿 Fertile window · ~$remaining days remaining"
}
else -> {
val days = prediction.daysUntilNextPeriod
if (days != null) "🩸 Next period in ~$days days · Cycle day ${prediction.currentCycleDay}"
else "🩸 Cycle day ${prediction.currentCycleDay}"
}
}
Surface(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 1.dp
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@Composable
private fun MonthHeader(month: YearMonth, onPrevious: () -> Unit, onNext: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPrevious) {
Icon(Icons.Default.ChevronLeft, contentDescription = "Previous month")
}
Text(
text = "${month.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${month.year}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
IconButton(onClick = onNext) {
Icon(Icons.Default.ChevronRight, contentDescription = "Next month")
}
}
}
@Composable
private fun CalendarGrid(
days: List<DayState>,
isFemale: Boolean,
onDayClick: (LocalDate) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) {
days.chunked(7).forEach { week ->
Row(modifier = Modifier.fillMaxWidth()) {
week.forEach { day ->
DayCell(
day = day,
isFemale = isFemale,
onClick = { onDayClick(day.date) },
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
private fun DayCell(
day: DayState,
isFemale: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val phaseColor = if (isFemale) phaseColor(day.phase) else null
val textAlpha = if (day.isCurrentMonth) 1f else 0.35f
Box(
modifier = modifier
.aspectRatio(1f)
.padding(1.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
// Phase color band at bottom
if (phaseColor != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.25f)
.align(Alignment.BottomCenter)
.background(
phaseColor.copy(
alpha = if (isPredicted(day.phase)) 0.45f else 0.75f
)
)
)
}
// 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 = day.date.dayOfMonth.toString(),
style = MaterialTheme.typography.bodySmall,
color = if (day.isToday) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha),
fontWeight = if (day.isToday) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 4.dp)
)
// Icon row
val icons = buildIconList(day, isFemale)
if (icons.isNotEmpty()) {
Row(
modifier = Modifier.padding(top = 1.dp),
horizontalArrangement = Arrangement.Center
) {
val visibleIcons = if (day.hasIntimacy) icons.take(2) else icons.take(3)
val overflow = icons.size - visibleIcons.size
visibleIcons.forEach { icon ->
Text(icon, fontSize = 9.sp, lineHeight = 10.sp)
}
if (overflow > 0) Text("+$overflow", fontSize = 7.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (day.hasIntimacy) Text("❤️", fontSize = 9.sp, lineHeight = 10.sp)
}
}
}
}
}
private fun buildIconList(day: DayState, isFemale: Boolean): List<String> {
val icons = mutableListOf<String>()
if (isFemale && (day.periodActive || day.phase == CyclePhase.MENSTRUATION_CONFIRMED || day.phase == CyclePhase.MENSTRUATION_PREDICTED)) {
icons.add("🩸")
}
day.conditionKeys.take(3).forEach { key ->
icons.add(conditionIcon(key))
}
return icons
}
private fun conditionIcon(key: String): String = when {
key.contains("HEAD") || key.contains("MIGRAINE") -> ""
key.contains("FATIGUE") || key.contains("EXHAUST") -> "😴"
key.contains("NAUSEA") || key.contains("VOMIT") -> "🤢"
key.contains("CRAMP") -> "💫"
key.contains("BACK") || key.contains("NECK") || key.contains("JOINT") -> "🦴"
key.contains("MOOD") || key.contains("ANXIOUS") || key.contains("IRRITABLE") -> "😤"
key.contains("HAPPY") || key.contains("CALM") -> "😊"
key.contains("FEVER") || key.contains("CHILL") -> "🤒"
else -> ""
}
private fun phaseColor(phase: CyclePhase): Color? = when (phase) {
CyclePhase.MENSTRUATION_CONFIRMED -> PeriodColor
CyclePhase.MENSTRUATION_PREDICTED -> PeriodPredictedColor
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> FertileColor
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> OvulationColor
CyclePhase.LUTEAL -> LutealColor
else -> null
}
private fun isPredicted(phase: CyclePhase): Boolean = phase == CyclePhase.MENSTRUATION_PREDICTED ||
phase == CyclePhase.FERTILE_WINDOW_PREDICTED || phase == CyclePhase.OVULATION_PREDICTED
fun parseColor(hex: String): Color = try {
Color(android.graphics.Color.parseColor(hex))
} catch (e: Exception) { Color(0xFFE91E63) }

View File

@@ -0,0 +1,207 @@
package com.hsdiary.ui.calendar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hsdiary.data.db.entity.DayLogEntity
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.CyclePhase
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.DayLogRepository
import com.hsdiary.data.repository.IntimacyRepository
import com.hsdiary.data.repository.ProfileRepository
import com.hsdiary.domain.CyclePredictionEngine
import com.hsdiary.domain.model.CyclePrediction
import com.hsdiary.ui.components.ProfileSwitchItem
import com.hsdiary.ui.theme.AvatarColors
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.YearMonth
import javax.inject.Inject
data class DayState(
val date: LocalDate,
val isToday: Boolean,
val isCurrentMonth: Boolean,
val periodActive: Boolean,
val phase: CyclePhase,
val conditionKeys: List<String>,
val hasIntimacy: Boolean
)
data class CalendarUiState(
val activeProfile: ProfileEntity? = null,
val profiles: List<ProfileEntity> = emptyList(),
val currentMonth: YearMonth = YearMonth.now(),
val dayStates: List<DayState> = emptyList(),
val prediction: CyclePrediction? = null,
val firstDayOfWeek: Int = 1,
val showProfileSheet: Boolean = false
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class CalendarViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val dayLogRepository: DayLogRepository,
private val cycleRepository: CycleRepository,
private val intimacyRepository: IntimacyRepository,
private val predictionEngine: CyclePredictionEngine,
private val userPreferences: UserPreferences
) : ViewModel() {
private val _currentMonth = MutableStateFlow(YearMonth.now())
private val _showProfileSheet = MutableStateFlow(false)
val uiState: StateFlow<CalendarUiState> = combine(
userPreferences.activeProfileId,
profileRepository.getAllProfiles(),
_currentMonth,
userPreferences.firstDayOfWeek,
_showProfileSheet
) { activeId, profiles, month, fdow, showSheet ->
Triple(activeId to profiles, month to fdow, showSheet)
}.flatMapLatest { (profileData, monthData, showSheet) ->
val (activeId, profiles) = profileData
val (month, fdow) = monthData
val activeProfile = profiles.find { it.id == activeId } ?: profiles.firstOrNull()
if (activeProfile == null) {
return@flatMapLatest flowOf(CalendarUiState(showProfileSheet = showSheet))
}
val isFemale = activeProfile.profileType == ProfileType.FEMALE.name
val rangeStart = month.atDay(1).minusDays(6)
val rangeEnd = month.atEndOfMonth().plusDays(6)
combine(
dayLogRepository.getDayLogsInRange(activeProfile.id, rangeStart.toString(), rangeEnd.toString()),
cycleRepository.getCycleRecords(activeProfile.id)
) { dayLogs, cycleRecords ->
val today = LocalDate.now()
val prediction = if (isFemale) {
predictionEngine.buildPrediction(
records = cycleRecords,
defaultCycleLength = activeProfile.cycleLengthDefault,
today = today,
rangeStart = rangeStart,
rangeEnd = rangeEnd
)
} else null
val intimacyDates = intimacyRepository.getDatesWithLogs(
activeProfile.id, rangeStart.toString(), rangeEnd.toString()
).toSet()
val dayLogMap = dayLogs.associateBy { it.date }
val conditionMap = buildConditionMap(dayLogs, activeProfile.id, rangeStart.toString(), rangeEnd.toString())
val days = buildCalendarDays(month, fdow, today, dayLogMap, conditionMap, intimacyDates, prediction)
CalendarUiState(
activeProfile = activeProfile,
profiles = profiles,
currentMonth = month,
dayStates = days,
prediction = prediction,
firstDayOfWeek = fdow,
showProfileSheet = showSheet
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CalendarUiState())
private suspend fun buildConditionMap(
dayLogs: List<DayLogEntity>,
profileId: Long,
startDate: String,
endDate: String
): Map<String, List<String>> {
val allConditions = dayLogRepository.getConditionsInRange(profileId, startDate, endDate)
val dayLogById = dayLogs.associateBy { it.id }
return allConditions
.groupBy { entry -> dayLogById[entry.dayLogId]?.date ?: "" }
.filter { it.key.isNotEmpty() }
.mapValues { (_, conds) -> conds.map { it.conditionKey } }
}
private fun buildCalendarDays(
month: YearMonth,
firstDayOfWeek: Int,
today: LocalDate,
dayLogMap: Map<String, DayLogEntity>,
conditionMap: Map<String, List<String>>,
intimacyDates: Set<String>,
prediction: CyclePrediction?
): List<DayState> {
val firstOfMonth = month.atDay(1)
// offset: how many blank days before the 1st
val dayOfWeek = firstOfMonth.dayOfWeek.value % 7 // 0=Sun..6=Sat
val offset = if (firstDayOfWeek == 1) dayOfWeek else (dayOfWeek + 6) % 7
val result = mutableListOf<DayState>()
// preceding month days
for (i in offset - 1 downTo 0) {
val date = firstOfMonth.minusDays(i.toLong() + 1)
result.add(makeDayState(date, today, false, dayLogMap, conditionMap, intimacyDates, prediction))
}
// current month
for (day in 1..month.lengthOfMonth()) {
val date = month.atDay(day)
result.add(makeDayState(date, today, true, dayLogMap, conditionMap, intimacyDates, prediction))
}
// trailing days to complete last row
val remaining = (7 - result.size % 7) % 7
for (i in 1..remaining) {
val date = month.atEndOfMonth().plusDays(i.toLong())
result.add(makeDayState(date, today, false, dayLogMap, conditionMap, intimacyDates, prediction))
}
return result
}
private fun makeDayState(
date: LocalDate,
today: LocalDate,
isCurrentMonth: Boolean,
dayLogMap: Map<String, DayLogEntity>,
conditionMap: Map<String, List<String>>,
intimacyDates: Set<String>,
prediction: CyclePrediction?
): DayState {
val key = date.toString()
val log = dayLogMap[key]
val phase = prediction?.phaseMap?.get(date) ?: CyclePhase.NO_DATA
return DayState(
date = date,
isToday = date == today,
isCurrentMonth = isCurrentMonth,
periodActive = log?.periodActive ?: false,
phase = phase,
conditionKeys = conditionMap[key] ?: emptyList(),
hasIntimacy = intimacyDates.contains(key)
)
}
fun previousMonth() { _currentMonth.value = _currentMonth.value.minusMonths(1) }
fun nextMonth() { _currentMonth.value = _currentMonth.value.plusMonths(1) }
fun showProfileSheet() { _showProfileSheet.value = true }
fun hideProfileSheet() { _showProfileSheet.value = false }
fun switchProfile(profileId: Long) {
viewModelScope.launch { userPreferences.setActiveProfileId(profileId) }
}
fun getProfileSwitchItems(state: CalendarUiState): List<ProfileSwitchItem> {
return state.profiles.map { p ->
val color = try {
android.graphics.Color.parseColor(p.avatarColor)
.let { androidx.compose.ui.graphics.Color(it) }
} catch (e: Exception) { AvatarColors[0] }
ProfileSwitchItem(p.id, p.name, color, p.id == state.activeProfile?.id)
}
}
}

View File

@@ -0,0 +1,77 @@
package com.hsdiary.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
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.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ProfileChip(
name: String,
avatarColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(avatarColor),
contentAlignment = Alignment.Center
) {
Text(
text = name.take(1).uppercase(),
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
}
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
@Composable
fun AvatarDot(
name: String,
avatarColor: Color,
size: Int = 36,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size.dp)
.clip(CircleShape)
.background(avatarColor),
contentAlignment = Alignment.Center
) {
Text(
text = name.take(1).uppercase(),
color = Color.White,
fontSize = (size * 0.38).sp,
fontWeight = FontWeight.Bold
)
}
}

View File

@@ -0,0 +1,78 @@
package com.hsdiary.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
data class ProfileSwitchItem(
val id: Long,
val name: String,
val avatarColor: Color,
val isActive: Boolean
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileSwitchSheet(
profiles: List<ProfileSwitchItem>,
onProfileSelected: (Long) -> Unit,
onDismiss: () -> Unit
) {
var confirmTarget by remember { mutableStateOf<ProfileSwitchItem?>(null) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp)) {
Text(
text = "Switch Profile",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
profiles.forEach { profile ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (!profile.isActive) confirmTarget = profile
else onDismiss()
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
AvatarDot(name = profile.name, avatarColor = profile.avatarColor)
Text(
text = profile.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
if (profile.isActive) {
Badge { Text("Active") }
}
}
}
}
}
confirmTarget?.let { target ->
AlertDialog(
onDismissRequest = { confirmTarget = null },
title = { Text("Switch Profile") },
text = { Text("Switch to ${target.name}?") },
confirmButton = {
TextButton(onClick = {
onProfileSelected(target.id)
confirmTarget = null
onDismiss()
}) { Text("Switch") }
},
dismissButton = {
TextButton(onClick = { confirmTarget = null }) { Text("Cancel") }
}
)
}
}

View File

@@ -0,0 +1,516 @@
package com.hsdiary.ui.daydetail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.hsdiary.data.db.entity.ConditionDefinitionEntity
import com.hsdiary.data.db.entity.IntimacyLogEntity
import com.hsdiary.data.model.CyclePhase
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DayDetailScreen(
date: String,
onBack: () -> Unit,
viewModel: DayDetailViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
var savedSnackbar by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(savedSnackbar) {
if (savedSnackbar) {
snackbarHostState.showSnackbar("Saved")
savedSnackbar = false
}
}
val dateLabel = remember(date) {
LocalDate.parse(date).format(DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.getDefault()))
}
BackHandler {
if (state.isDirty) {
viewModel.saveAndExit()
savedSnackbar = true
}
onBack()
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(dateLabel, style = MaterialTheme.typography.titleMedium) },
navigationIcon = {
IconButton(onClick = {
if (state.isDirty) viewModel.saveAndExit()
onBack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(bottom = 32.dp)
) {
// Cycle section (female only)
if (state.isFemale) {
item {
CycleSection(
state = state,
onTogglePeriodStart = viewModel::togglePeriodStart,
onTogglePeriodEnd = viewModel::togglePeriodEnd
)
}
item { HorizontalDivider() }
}
// Conditions section
item {
ConditionsSection(
definitions = state.definitions,
selectedConditions = state.conditions,
onToggle = viewModel::toggleCondition,
onRatingChange = viewModel::setConditionRating
)
}
// Notes
item {
NotesSection(
notes = state.notes,
onNotesChange = viewModel::updateNotes
)
}
item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) }
// Intimacy section
item {
IntimacySection(
logs = state.intimacyLogs,
profiles = state.allProfiles,
activeProfileId = state.activeProfile?.id ?: 0L,
isFemaleInFertileWindow = state.isFemale && (
state.currentPhase == CyclePhase.FERTILE_WINDOW ||
state.currentPhase == CyclePhase.OVULATION
),
onAdd = { pType, pName, time, protected ->
viewModel.addIntimacyLog(pType, pName, time, protected)
},
onDelete = viewModel::deleteIntimacyLog
)
}
}
}
}
@Composable
private fun CycleSection(
state: DayDetailUiState,
onTogglePeriodStart: () -> Unit,
onTogglePeriodEnd: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
// Phase / cycle day header
val phaseLabel = when (state.currentPhase) {
CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation"
CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation"
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window"
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "Ovulation"
CyclePhase.LUTEAL -> "Luteal Phase"
CyclePhase.FOLLICULAR -> "Follicular Phase"
else -> null
}
if (state.cycleDay > 0 && phaseLabel != null) {
Text(
"Cycle day ${state.cycleDay} · $phaseLabel",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
}
// Period day-of-period badge
if (state.periodActive && state.periodDayNumber > 0) {
Text(
"Period day ${state.periodDayNumber}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
}
Text("Period", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip(
selected = state.isPeriodStart,
onClick = onTogglePeriodStart,
label = { Text("Period started") },
leadingIcon = {
Icon(
if (state.isPeriodStart) Icons.Default.Check else Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
FilterChip(
selected = state.isPeriodEnd,
onClick = onTogglePeriodEnd,
enabled = state.isPeriodStart || state.periodActive || state.isPeriodEnd,
label = { Text("Period ended") },
leadingIcon = {
Icon(
if (state.isPeriodEnd) Icons.Default.Check else Icons.Default.Stop,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
// Predicted-period nudge
if (state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED && !state.isPeriodStart) {
Spacer(Modifier.height(8.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Text("Period predicted today — tap \"Period started\" to confirm", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ConditionsSection(
definitions: List<ConditionDefinitionEntity>,
selectedConditions: Map<String, Int>,
onToggle: (String) -> Unit,
onRatingChange: (String, Int) -> Unit
) {
val grouped = remember(definitions) {
definitions.groupBy { it.category }
}
var expandedCategories by remember { mutableStateOf(setOf<String>()) }
Column(modifier = Modifier.padding(16.dp)) {
Text("Symptoms", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp))
grouped.forEach { (category, items) ->
val categoryLabel = category.replace("_", " ").lowercase()
.replaceFirstChar { it.uppercase() }
val isExpanded = category in expandedCategories
val hasSelected = items.any { selectedConditions.containsKey(it.conditionKey) }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
expandedCategories = if (isExpanded)
expandedCategories - category
else
expandedCategories + category
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = categoryLabel,
style = MaterialTheme.typography.labelLarge,
color = if (hasSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
if (hasSelected) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text("${items.count { selectedConditions.containsKey(it.conditionKey) }}")
}
}
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
if (isExpanded) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items.forEach { def ->
val selected = selectedConditions.containsKey(def.conditionKey)
FilterChip(
selected = selected,
onClick = { onToggle(def.conditionKey) },
label = { Text(def.displayName, style = MaterialTheme.typography.bodySmall) }
)
}
}
// Rating row for selected items in this category
items.filter { selectedConditions.containsKey(it.conditionKey) }.forEach { def ->
val rating = selectedConditions[def.conditionKey] ?: 3
RatingRow(
label = def.displayName,
rating = rating,
onRatingChange = { onRatingChange(def.conditionKey, it) }
)
}
}
}
}
}
@Composable
private fun RatingRow(label: String, rating: Int, onRatingChange: (Int) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.width(120.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
(1..5).forEach { i ->
Box(
modifier = Modifier
.size(22.dp)
.background(
if (i <= rating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(4.dp)
)
.clickable { onRatingChange(i) },
contentAlignment = Alignment.Center
) {
Text(
i.toString(),
style = MaterialTheme.typography.labelSmall,
color = if (i <= rating) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun NotesSection(notes: String, onNotesChange: (String) -> Unit) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
OutlinedTextField(
value = notes,
onValueChange = { if (it.length <= 256) onNotesChange(it) },
label = { Text("Notes") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4,
supportingText = { Text("${notes.length}/256") }
)
}
}
@Composable
private fun IntimacySection(
logs: List<IntimacyLogEntity>,
profiles: List<com.hsdiary.data.db.entity.ProfileEntity>,
activeProfileId: Long,
isFemaleInFertileWindow: Boolean,
onAdd: (String, String?, String?, Boolean) -> Unit,
onDelete: (IntimacyLogEntity) -> Unit
) {
var isExpanded by remember { mutableStateOf(true) }
var showAddForm by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Intimacy",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null
)
}
if (isExpanded) {
logs.forEach { log ->
IntimacyLogCard(
log = log,
isFemaleInFertileWindow = isFemaleInFertileWindow,
onDelete = { onDelete(log) }
)
}
if (showAddForm) {
AddIntimacyForm(
profiles = profiles,
activeProfileId = activeProfileId,
onConfirm = { pType, pName, time, protected ->
onAdd(pType, pName, time, protected)
showAddForm = false
},
onCancel = { showAddForm = false }
)
} else {
TextButton(
onClick = { showAddForm = true },
modifier = Modifier.padding(top = 4.dp)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Add encounter")
}
}
}
}
}
@Composable
private fun IntimacyLogCard(
log: IntimacyLogEntity,
isFemaleInFertileWindow: Boolean,
onDelete: () -> Unit
) {
OutlinedCard(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("❤️ · ${log.participantName ?: log.participantType} · ${log.timeOfDay ?: ""} · ${if (log.protected) "Protected" else "Unprotected"}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDelete, modifier = Modifier.size(20.dp)) {
Icon(Icons.Default.Close, contentDescription = "Delete", modifier = Modifier.size(14.dp))
}
}
if (isFemaleInFertileWindow && !log.protected) {
Text(
"🌿 Unprotected · Fertile window",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)
)
}
}
}
@Composable
private fun AddIntimacyForm(
profiles: List<com.hsdiary.data.db.entity.ProfileEntity>,
activeProfileId: Long,
onConfirm: (String, String?, String?, Boolean) -> Unit,
onCancel: () -> Unit
) {
var participantType by remember { mutableStateOf("OTHER") }
var participantName by remember { mutableStateOf("") }
var timeOfDay by remember { mutableStateOf("") }
var protected by remember { mutableStateOf(true) }
val otherProfile = profiles.firstOrNull { it.id != activeProfileId }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("New encounter", style = MaterialTheme.typography.titleSmall)
// Participant selector
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (otherProfile != null) {
FilterChip(
selected = participantType == "PARTNER",
onClick = { participantType = "PARTNER"; participantName = otherProfile.name },
label = { Text(otherProfile.name) }
)
}
FilterChip(
selected = participantType == "OTHER",
onClick = { participantType = "OTHER"; participantName = "" },
label = { Text("Other") }
)
}
if (participantType == "OTHER") {
OutlinedTextField(
value = participantName,
onValueChange = { if (it.length <= 32) participantName = it },
label = { Text("Name (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
OutlinedTextField(
value = timeOfDay,
onValueChange = { timeOfDay = it },
label = { Text("Time (optional, HH:MM)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Protected", style = MaterialTheme.typography.bodyMedium)
Switch(checked = protected, onCheckedChange = { protected = it })
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") }
Button(
onClick = {
onConfirm(
participantType,
participantName.ifBlank { null },
timeOfDay.ifBlank { null },
protected
)
},
modifier = Modifier.weight(1f)
) { Text("Add") }
}
}
}
}
@Composable
private fun BackHandler(onBack: () -> Unit) {
androidx.activity.compose.BackHandler(onBack = onBack)
}

View File

@@ -0,0 +1,258 @@
package com.hsdiary.ui.daydetail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hsdiary.data.db.entity.ConditionDefinitionEntity
import com.hsdiary.data.db.entity.DayLogEntity
import com.hsdiary.data.db.entity.IntimacyLogEntity
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.CyclePhase
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.DayLogRepository
import com.hsdiary.data.repository.IntimacyRepository
import com.hsdiary.data.repository.ProfileRepository
import com.hsdiary.domain.CyclePredictionEngine
import com.hsdiary.domain.model.CyclePrediction
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
data class DayDetailUiState(
val date: LocalDate = LocalDate.now(),
val activeProfile: ProfileEntity? = null,
val allProfiles: List<ProfileEntity> = emptyList(),
val dayLog: DayLogEntity? = null,
val periodActive: Boolean = false,
val isPeriodStart: Boolean = false,
val isPeriodEnd: Boolean = false,
val periodDayNumber: Int = 0, // day-of-period for this date (1 = first day)
val notes: String = "",
val conditions: Map<String, Int> = emptyMap(),
val definitions: List<ConditionDefinitionEntity> = emptyList(),
val intimacyLogs: List<IntimacyLogEntity> = emptyList(),
val prediction: CyclePrediction? = null,
val isFemale: Boolean = false,
val cycleDay: Int = 0,
val currentPhase: CyclePhase = CyclePhase.NO_DATA,
val isDirty: Boolean = false
)
@HiltViewModel
class DayDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val profileRepository: ProfileRepository,
private val dayLogRepository: DayLogRepository,
private val cycleRepository: CycleRepository,
private val intimacyRepository: IntimacyRepository,
private val predictionEngine: CyclePredictionEngine,
private val userPreferences: UserPreferences
) : ViewModel() {
private val dateStr: String = savedStateHandle["date"] ?: LocalDate.now().toString()
val date: LocalDate = LocalDate.parse(dateStr)
private val _uiState = MutableStateFlow(DayDetailUiState(date = date))
val uiState: StateFlow<DayDetailUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch { loadData() }
}
private suspend fun loadData() {
val activeId = userPreferences.activeProfileId.first()
val profiles = profileRepository.getAllProfilesOnce()
val activeProfile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() ?: return
val isFemale = activeProfile.profileType == ProfileType.FEMALE.name
val dayLog = dayLogRepository.getOrCreateDayLog(activeProfile.id, dateStr)
val conditions = dayLogRepository.getConditionsForDayOnce(dayLog.id)
.associate { it.conditionKey to it.rating }
val defsToShow = dayLogRepository.getDefinitionsForProfile(isFemale)
val intimacyLogs = intimacyRepository.getLogsForDayOnce(dateStr, activeProfile.id)
val cycleRecords = cycleRepository.getCycleRecordsOnce(activeProfile.id)
val prediction = if (isFemale) {
predictionEngine.buildPrediction(cycleRecords, activeProfile.cycleLengthDefault, LocalDate.now())
} else null
val phase = prediction?.phaseMap?.get(date) ?: CyclePhase.NO_DATA
val cycleDay = if (isFemale && prediction?.currentCycleStartDate != null) {
maxOf(1, (date.toEpochDay() - prediction.currentCycleStartDate.toEpochDay()).toInt() + 1)
} else 0
val isPeriodStart = isFemale && cycleRepository.isPeriodStart(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 periodDayNumber = if (isFemale && dayLog.periodActive) {
val record = cycleRepository.getRecordContainingDate(activeProfile.id, dateStr)
if (record != null) {
(date.toEpochDay() - LocalDate.parse(record.cycleStart).toEpochDay()).toInt() + 1
} else 0
} else 0
_uiState.value = DayDetailUiState(
date = date,
activeProfile = activeProfile,
allProfiles = profiles,
dayLog = dayLog,
periodActive = dayLog.periodActive,
isPeriodStart = isPeriodStart,
isPeriodEnd = isPeriodEnd,
periodDayNumber = periodDayNumber,
notes = dayLog.notes ?: "",
conditions = conditions,
definitions = defsToShow,
intimacyLogs = intimacyLogs,
prediction = prediction,
isFemale = isFemale,
cycleDay = cycleDay,
currentPhase = phase
)
intimacyRepository.getLogsForDay(dateStr, activeProfile.id)
.onEach { logs -> _uiState.value = _uiState.value.copy(intimacyLogs = logs) }
.launchIn(viewModelScope)
}
fun togglePeriodStart() {
viewModelScope.launch {
val state = _uiState.value
val profileId = state.activeProfile?.id ?: return@launch
val dayLog = state.dayLog ?: return@launch
if (state.isPeriodStart) {
// Remove: delete the cycle record that starts here, clear period_active on this day
cycleRepository.removePeriodStart(profileId, dateStr)
val updatedLog = dayLog.copy(periodActive = false)
dayLogRepository.upsertDayLog(updatedLog)
_uiState.value = state.copy(
isPeriodStart = false,
periodActive = false,
dayLog = updatedLog,
periodDayNumber = 0,
isDirty = true
)
} else {
// Mark as start: create a new cycle record, set period_active = true
val predictedStart = state.prediction?.nextPeriodDate
cycleRepository.startNewCycle(profileId, dateStr, predictedStart?.toString())
val updatedLog = dayLog.copy(periodActive = true)
dayLogRepository.upsertDayLog(updatedLog)
_uiState.value = state.copy(
isPeriodStart = true,
isPeriodEnd = false,
periodActive = true,
dayLog = updatedLog,
periodDayNumber = 1,
isDirty = true
)
}
}
}
fun togglePeriodEnd() {
viewModelScope.launch {
val state = _uiState.value
val profileId = state.activeProfile?.id ?: return@launch
val dayLog = state.dayLog ?: return@launch
if (state.isPeriodEnd) {
// Remove end date from the cycle record
cycleRepository.removePeriodEnd(profileId, dateStr)
_uiState.value = state.copy(isPeriodEnd = false, isDirty = true)
} else {
// Mark as end: close the current open cycle record at this date
cycleRepository.endCurrentCycle(profileId, dateStr)
// Also mark this day as a period day if not already
if (!state.periodActive) {
val updatedLog = dayLog.copy(periodActive = true)
dayLogRepository.upsertDayLog(updatedLog)
_uiState.value = state.copy(
isPeriodEnd = true,
periodActive = true,
dayLog = updatedLog,
isDirty = true
)
} else {
_uiState.value = state.copy(isPeriodEnd = true, isDirty = true)
}
}
}
}
fun toggleCondition(conditionKey: String) {
viewModelScope.launch {
val state = _uiState.value
val dayLog = state.dayLog ?: return@launch
val current = state.conditions.toMutableMap()
if (current.containsKey(conditionKey)) {
dayLogRepository.removeCondition(dayLog.id, conditionKey)
current.remove(conditionKey)
} else {
dayLogRepository.setCondition(dayLog.id, conditionKey, 3)
current[conditionKey] = 3
}
_uiState.value = state.copy(conditions = current, isDirty = true)
}
}
fun setConditionRating(conditionKey: String, rating: Int) {
viewModelScope.launch {
val state = _uiState.value
val dayLog = state.dayLog ?: return@launch
dayLogRepository.setCondition(dayLog.id, conditionKey, rating)
val current = state.conditions.toMutableMap()
current[conditionKey] = rating
_uiState.value = state.copy(conditions = current, isDirty = true)
}
}
fun updateNotes(notes: String) {
_uiState.value = _uiState.value.copy(notes = notes, isDirty = true)
}
fun saveNotes() {
viewModelScope.launch {
val state = _uiState.value
val dayLog = state.dayLog ?: return@launch
dayLogRepository.upsertDayLog(dayLog.copy(notes = state.notes.ifBlank { null }))
}
}
fun addIntimacyLog(participantType: String, participantName: String?, timeOfDay: String?, protected: Boolean) {
viewModelScope.launch {
val state = _uiState.value
val profileId = state.activeProfile?.id ?: return@launch
intimacyRepository.insertLog(
IntimacyLogEntity(
date = dateStr,
ownerProfileId = profileId,
participantType = participantType,
participantName = participantName,
timeOfDay = timeOfDay,
protected = protected,
shared = participantType != "OTHER"
)
)
_uiState.value = state.copy(isDirty = true)
}
}
fun deleteIntimacyLog(log: IntimacyLogEntity) {
viewModelScope.launch { intimacyRepository.deleteLog(log) }
}
fun saveAndExit() {
viewModelScope.launch {
val state = _uiState.value
val dayLog = state.dayLog ?: return@launch
dayLogRepository.upsertDayLog(dayLog.copy(notes = state.notes.ifBlank { null }))
}
}
}

View File

@@ -0,0 +1,207 @@
package com.hsdiary.ui.insights
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.hsdiary.data.model.CyclePhase
import com.hsdiary.domain.model.CyclePrediction
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CycleInsightsScreen(
onBack: () -> Unit,
viewModel: CycleInsightsViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Cycle Insights") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
val prediction = state.prediction
if (prediction == null || prediction.cyclesLogged == 0) {
Box(
Modifier.fillMaxSize().padding(padding).padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("🩸", style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(16.dp))
Text("No cycle data yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Log your first period on the calendar to begin tracking.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Current phase card
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Current Cycle", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
val phaseText = when (prediction.currentPhase) {
CyclePhase.MENSTRUATION_CONFIRMED, CyclePhase.MENSTRUATION_PREDICTED -> "🩸 Menstruation"
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "🌿 Fertile Window"
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "🌿 Ovulation Day"
CyclePhase.LUTEAL -> "🌙 Luteal Phase"
CyclePhase.FOLLICULAR -> "🌱 Follicular Phase"
else -> ""
}
Text(phaseText, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
if (prediction.currentCycleDay > 0) {
Text("Day ${prediction.currentCycleDay}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// Stats row
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
StatCard(
label = "Avg Cycle",
value = "${prediction.averageCycleLength} days",
modifier = Modifier.weight(1f)
)
StatCard(
label = "Cycles Logged",
value = "${prediction.cyclesLogged}",
modifier = Modifier.weight(1f)
)
StatCard(
label = "Prediction",
value = "Tier ${prediction.tier}",
modifier = Modifier.weight(1f)
)
}
// Next period
prediction.nextPeriodDate?.let { next ->
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Upcoming", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
val fmt = DateTimeFormatter.ofPattern("MMMM d", Locale.getDefault())
Text("Next period: ${next.format(fmt)}", style = MaterialTheme.typography.bodyLarge)
prediction.daysUntilNextPeriod?.let { days ->
Text("In ~$days days", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
prediction.fertileWindowStart?.let { fw ->
val fwEnd = prediction.fertileWindowEnd
Text(
"Fertile window: ${fw.format(fmt)} ${fwEnd?.format(fmt) ?: ""}",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
// Cycle length bar chart
if (state.recentCycles.size >= 2) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Cycle History", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(12.dp))
CycleLengthBarChart(cycles = state.recentCycles.mapNotNull { it.cycleLength })
}
}
}
}
}
}
@Composable
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Column(
modifier = Modifier.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun CycleLengthBarChart(cycles: List<Int>) {
if (cycles.isEmpty()) return
val maxLen = cycles.max().toFloat()
val barColor = MaterialTheme.colorScheme.primary
val avgColor = MaterialTheme.colorScheme.secondary
val avg = cycles.average().toFloat()
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
val barWidth = (size.width / (cycles.size * 1.5f))
val spacing = barWidth * 0.5f
cycles.forEachIndexed { i, length ->
val barHeight = (length / maxLen) * size.height * 0.85f
val x = i * (barWidth + spacing)
drawRect(
color = barColor,
topLeft = Offset(x, size.height - barHeight),
size = Size(barWidth, barHeight)
)
}
// average line
val avgY = size.height - (avg / maxLen) * size.height * 0.85f
drawLine(
color = avgColor,
start = Offset(0f, avgY),
end = Offset(size.width, avgY),
strokeWidth = 2.dp.toPx()
)
}
Spacer(Modifier.height(4.dp))
Text(
"Last ${cycles.size} cycles · Average: ${avg.toInt()} days",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}

View File

@@ -0,0 +1,65 @@
package com.hsdiary.ui.insights
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hsdiary.data.db.entity.CycleRecordEntity
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.ProfileRepository
import com.hsdiary.domain.CyclePredictionEngine
import com.hsdiary.domain.model.CyclePrediction
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
data class CycleInsightsUiState(
val profile: ProfileEntity? = null,
val prediction: CyclePrediction? = null,
val recentCycles: List<CycleRecordEntity> = emptyList(),
val isLoading: Boolean = true
)
@HiltViewModel
class CycleInsightsViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val cycleRepository: CycleRepository,
private val predictionEngine: CyclePredictionEngine,
private val userPreferences: UserPreferences
) : ViewModel() {
private val _uiState = MutableStateFlow(CycleInsightsUiState())
val uiState: StateFlow<CycleInsightsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch { load() }
}
private suspend fun load() {
val activeId = userPreferences.activeProfileId.first()
val profiles = profileRepository.getAllProfilesOnce()
val profile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() ?: return
if (profile.profileType != ProfileType.FEMALE.name) {
_uiState.value = CycleInsightsUiState(profile = profile, isLoading = false)
return
}
cycleRepository.getCycleRecords(profile.id)
.onEach { records ->
val prediction = predictionEngine.buildPrediction(
records = records,
defaultCycleLength = profile.cycleLengthDefault,
today = LocalDate.now()
)
_uiState.value = CycleInsightsUiState(
profile = profile,
prediction = prediction,
recentCycles = records.takeLast(12),
isLoading = false
)
}
.launchIn(viewModelScope)
}
}

View File

@@ -0,0 +1,60 @@
package com.hsdiary.ui.navigation
import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.hsdiary.ui.calendar.CalendarScreen
import com.hsdiary.ui.daydetail.DayDetailScreen
import com.hsdiary.ui.insights.CycleInsightsScreen
import com.hsdiary.ui.onboarding.OnboardingScreen
import com.hsdiary.ui.onboarding.OnboardingViewModel
import com.hsdiary.ui.settings.SettingsScreen
import com.hsdiary.ui.trends.HealthTrendsScreen
@Composable
fun AppNavigation() {
val navController = rememberNavController()
val onboardingVm: OnboardingViewModel = hiltViewModel()
val onboardingComplete by onboardingVm.onboardingComplete.collectAsState(initial = null)
if (onboardingComplete == null) return // wait for prefs to load
NavHost(
navController = navController,
startDestination = if (onboardingComplete == true) Screen.Calendar.route else Screen.Onboarding.route
) {
composable(Screen.Onboarding.route) {
OnboardingScreen(
onComplete = {
navController.navigate(Screen.Calendar.route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
}
}
)
}
composable(Screen.Calendar.route) {
CalendarScreen(
onDayClick = { date -> navController.navigate(Screen.DayDetail.createRoute(date)) },
onInsightsClick = { navController.navigate(Screen.CycleInsights.route) },
onTrendsClick = { navController.navigate(Screen.HealthTrends.route) },
onSettingsClick = { navController.navigate(Screen.Settings.route) }
)
}
composable(Screen.DayDetail.route) { backstack ->
val date = backstack.arguments?.getString("date") ?: return@composable
DayDetailScreen(date = date, onBack = { navController.popBackStack() })
}
composable(Screen.CycleInsights.route) {
CycleInsightsScreen(onBack = { navController.popBackStack() })
}
composable(Screen.HealthTrends.route) {
HealthTrendsScreen(onBack = { navController.popBackStack() })
}
composable(Screen.Settings.route) {
SettingsScreen(onBack = { navController.popBackStack() })
}
}
}

View File

@@ -0,0 +1,12 @@
package com.hsdiary.ui.navigation
sealed class Screen(val route: String) {
object Onboarding : Screen("onboarding")
object Calendar : Screen("calendar")
object DayDetail : Screen("day_detail/{date}") {
fun createRoute(date: String) = "day_detail/$date"
}
object CycleInsights : Screen("cycle_insights")
object HealthTrends : Screen("health_trends")
object Settings : Screen("settings")
}

View File

@@ -0,0 +1,165 @@
package com.hsdiary.ui.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.hilt.navigation.compose.hiltViewModel
import com.hsdiary.data.model.ProfileType
import com.hsdiary.ui.theme.AvatarColors
@Composable
fun OnboardingScreen(
onComplete: () -> Unit,
viewModel: OnboardingViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
when (state.step) {
0 -> WelcomeStep(onNext = { viewModel.nextStep() })
1 -> ProfileSetupStep(
title = "Create your profile",
name = state.profile1Name,
colorIndex = state.profile1ColorIndex,
profileType = state.profile1Type,
onNameChange = viewModel::updateProfile1Name,
onColorChange = viewModel::updateProfile1Color,
onTypeChange = viewModel::updateProfile1Type,
onNext = { viewModel.nextStep() },
canSkip = false
)
2 -> ProfileSetupStep(
title = "Add a second profile",
subtitle = "Optional — can be added later in Settings",
name = state.profile2Name,
colorIndex = state.profile2ColorIndex,
profileType = state.profile2Type,
onNameChange = viewModel::updateProfile2Name,
onColorChange = viewModel::updateProfile2Color,
onTypeChange = viewModel::updateProfile2Type,
onNext = { viewModel.completeOnboarding(onComplete) },
canSkip = true,
onSkip = { viewModel.completeOnboarding(onComplete) },
isLoading = state.isLoading
)
}
}
@Composable
private fun WelcomeStep(onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🩺", style = MaterialTheme.typography.displayLarge)
Spacer(Modifier.height(24.dp))
Text(
text = "H&S Diary",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(12.dp))
Text(
text = "Your private health & cycle tracker.\nAll data stays on your device.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(48.dp))
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) {
Text("Get Started")
}
}
}
@Composable
private fun ProfileSetupStep(
title: String,
subtitle: String? = null,
name: String,
colorIndex: Int,
profileType: ProfileType,
onNameChange: (String) -> Unit,
onColorChange: (Int) -> Unit,
onTypeChange: (ProfileType) -> Unit,
onNext: () -> Unit,
canSkip: Boolean,
onSkip: (() -> Unit)? = null,
isLoading: Boolean = false
) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
if (subtitle != null) {
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedTextField(
value = name,
onValueChange = { if (it.length <= 32) onNameChange(it) },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Profile type selector
Text("Profile type", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ProfileType.values().forEach { type ->
FilterChip(
selected = profileType == type,
onClick = { onTypeChange(type) },
label = { Text(if (type == ProfileType.FEMALE) "Female" else "Male") }
)
}
}
// Color selector
Text("Avatar color", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AvatarColors.forEachIndexed { idx, color ->
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(color)
.then(
if (idx == colorIndex) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
else Modifier
)
.clickable { onColorChange(idx) }
)
}
}
Spacer(Modifier.weight(1f))
Button(
onClick = onNext,
enabled = !isLoading && (name.isNotBlank() || canSkip),
modifier = Modifier.fillMaxWidth()
) {
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
else Text(if (canSkip && name.isBlank()) "Skip" else "Continue")
}
if (canSkip && name.isNotBlank()) {
TextButton(onClick = { onSkip?.invoke() }, modifier = Modifier.fillMaxWidth()) {
Text("Skip for now")
}
}
}
}

View File

@@ -0,0 +1,77 @@
package com.hsdiary.ui.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.ProfileRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class OnboardingUiState(
val step: Int = 0, // 0=welcome, 1=profile1, 2=profile2_prompt, 3=complete
val profile1Name: String = "",
val profile1ColorIndex: Int = 0,
val profile1Type: ProfileType = ProfileType.FEMALE,
val profile2Name: String = "",
val profile2ColorIndex: Int = 1,
val profile2Type: ProfileType = ProfileType.MALE,
val isLoading: Boolean = false
)
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val userPreferences: UserPreferences
) : ViewModel() {
val onboardingComplete = userPreferences.onboardingComplete
private val _uiState = MutableStateFlow(OnboardingUiState())
val uiState: StateFlow<OnboardingUiState> = _uiState.asStateFlow()
fun updateProfile1Name(name: String) { _uiState.value = _uiState.value.copy(profile1Name = name) }
fun updateProfile1Color(idx: Int) { _uiState.value = _uiState.value.copy(profile1ColorIndex = idx) }
fun updateProfile1Type(type: ProfileType) { _uiState.value = _uiState.value.copy(profile1Type = type) }
fun updateProfile2Name(name: String) { _uiState.value = _uiState.value.copy(profile2Name = name) }
fun updateProfile2Color(idx: Int) { _uiState.value = _uiState.value.copy(profile2ColorIndex = idx) }
fun updateProfile2Type(type: ProfileType) { _uiState.value = _uiState.value.copy(profile2Type = type) }
fun nextStep() { _uiState.value = _uiState.value.copy(step = _uiState.value.step + 1) }
fun completeOnboarding(onDone: () -> Unit) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
val state = _uiState.value
val colorHexes = listOf(
"#E91E63","#9C27B0","#2196F3","#009688",
"#4CAF50","#FF9800","#FF4081","#7C4DFF"
)
val id1 = profileRepository.insertProfile(
ProfileEntity(
name = state.profile1Name.trim().ifEmpty { "Profile 1" },
avatarColor = colorHexes[state.profile1ColorIndex],
profileType = state.profile1Type.name
)
)
userPreferences.setActiveProfileId(id1)
if (state.profile2Name.isNotBlank()) {
profileRepository.insertProfile(
ProfileEntity(
name = state.profile2Name.trim(),
avatarColor = colorHexes[state.profile2ColorIndex],
profileType = state.profile2Type.name
)
)
}
userPreferences.setOnboardingComplete(true)
onDone()
}
}
}

View File

@@ -0,0 +1,242 @@
package com.hsdiary.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.ui.calendar.parseColor
import com.hsdiary.ui.theme.AvatarColors
private val colorHexes = listOf(
"#E91E63","#9C27B0","#2196F3","#009688",
"#4CAF50","#FF9800","#FF4081","#7C4DFF"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
var clearProfileTarget by remember { mutableStateOf<Long?>(null) }
var showClearAll by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
// App settings
SettingsSection("App") {
// First day of week
ListItem(
headlineContent = { Text("First day of week") },
trailingContent = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = state.firstDayOfWeek == 1,
onClick = { viewModel.setFirstDayOfWeek(1) },
label = { Text("Sunday") }
)
FilterChip(
selected = state.firstDayOfWeek == 2,
onClick = { viewModel.setFirstDayOfWeek(2) },
label = { Text("Monday") }
)
}
}
)
HorizontalDivider()
// Theme
ListItem(
headlineContent = { Text("App theme") },
trailingContent = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Light", "Dark", "System").forEach { theme ->
val key = theme.uppercase()
FilterChip(
selected = state.appTheme == key,
onClick = { viewModel.setAppTheme(key) },
label = { Text(theme) }
)
}
}
}
)
}
// Profile settings
state.profiles.forEach { profile ->
SettingsSection("Profile: ${profile.name}") {
ProfileSettingsCard(
profile = profile,
onNameChange = { viewModel.updateProfileName(profile.id, it) },
onColorChange = { viewModel.updateProfileColor(profile.id, it) }
)
HorizontalDivider()
ListItem(
headlineContent = { Text("Clear profile data", color = MaterialTheme.colorScheme.error) },
supportingContent = { Text("Removes all logs for this profile") },
modifier = Modifier.clickable { clearProfileTarget = profile.id }
)
}
}
// Data management
SettingsSection("Data") {
ListItem(
headlineContent = { Text("Clear all data", color = MaterialTheme.colorScheme.error) },
supportingContent = { Text("Removes all profiles and data — cannot be undone") },
modifier = Modifier.clickable { showClearAll = true }
)
}
Spacer(Modifier.height(32.dp))
Text(
"H&S Diary · All data stored locally on this device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
// Confirm clear profile
clearProfileTarget?.let { profileId ->
val profile = state.profiles.find { it.id == profileId }
AlertDialog(
onDismissRequest = { clearProfileTarget = null },
title = { Text("Clear data?") },
text = { Text("This will remove all logs for ${profile?.name}. This cannot be undone.") },
confirmButton = {
TextButton(onClick = {
viewModel.clearProfileData(profileId)
clearProfileTarget = null
}, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
Text("Clear")
}
},
dismissButton = {
TextButton(onClick = { clearProfileTarget = null }) { Text("Cancel") }
}
)
}
// Confirm clear all
if (showClearAll) {
AlertDialog(
onDismissRequest = { showClearAll = false },
title = { Text("Clear all data?") },
text = { Text("This will remove all profiles, logs, and reset the app. This CANNOT be undone.") },
confirmButton = {
TextButton(
onClick = { viewModel.clearAllData(); showClearAll = false },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Clear Everything") }
},
dismissButton = {
TextButton(onClick = { showClearAll = false }) { Text("Cancel") }
}
)
}
}
@Composable
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text(
title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column { content() }
}
}
}
@Composable
private fun ProfileSettingsCard(
profile: ProfileEntity,
onNameChange: (String) -> Unit,
onColorChange: (String) -> Unit
) {
var editingName by remember(profile.id) { mutableStateOf(profile.name) }
var nameEditMode by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(16.dp)) {
// Name
if (nameEditMode) {
OutlinedTextField(
value = editingName,
onValueChange = { if (it.length <= 32) editingName = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
TextButton(onClick = {
onNameChange(editingName)
nameEditMode = false
}) { Text("Save") }
}
)
} else {
ListItem(
headlineContent = { Text(profile.name) },
supportingContent = { Text("Tap to edit name") },
modifier = Modifier.clickable { nameEditMode = true }
)
}
Spacer(Modifier.height(8.dp))
Text("Avatar color", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(horizontal = 4.dp)) {
colorHexes.forEachIndexed { idx, hex ->
val color = parseColor(hex)
val isSelected = profile.avatarColor == hex
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(color)
.then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
.clickable { onColorChange(hex) }
)
}
}
}
}

View File

@@ -0,0 +1,95 @@
package com.hsdiary.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.DayLogRepository
import com.hsdiary.data.repository.IntimacyRepository
import com.hsdiary.data.repository.ProfileRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val profiles: List<ProfileEntity> = emptyList(),
val activeProfileId: Long? = null,
val firstDayOfWeek: Int = 1,
val appTheme: String = "SYSTEM",
val isLoading: Boolean = false,
val confirmClearProfileId: Long? = null,
val confirmClearAll: Boolean = false
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val dayLogRepository: DayLogRepository,
private val cycleRepository: CycleRepository,
private val intimacyRepository: IntimacyRepository,
private val userPreferences: UserPreferences
) : ViewModel() {
val uiState: StateFlow<SettingsUiState> = combine(
profileRepository.getAllProfiles(),
userPreferences.activeProfileId,
userPreferences.firstDayOfWeek,
userPreferences.appTheme
) { profiles, activeId, fdow, theme ->
SettingsUiState(profiles = profiles, activeProfileId = activeId, firstDayOfWeek = fdow, appTheme = theme)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SettingsUiState())
fun setFirstDayOfWeek(day: Int) {
viewModelScope.launch { userPreferences.setFirstDayOfWeek(day) }
}
fun setAppTheme(theme: String) {
viewModelScope.launch { userPreferences.setAppTheme(theme) }
}
fun updateProfileName(profileId: Long, name: String) {
viewModelScope.launch {
val profile = profileRepository.getProfileById(profileId) ?: return@launch
profileRepository.updateProfile(profile.copy(name = name.trim()))
}
}
fun updateProfileColor(profileId: Long, colorHex: String) {
viewModelScope.launch {
val profile = profileRepository.getProfileById(profileId) ?: return@launch
profileRepository.updateProfile(profile.copy(avatarColor = colorHex))
}
}
fun updateReproductiveStatus(profileId: Long, statusJson: String) {
viewModelScope.launch {
val profile = profileRepository.getProfileById(profileId) ?: return@launch
profileRepository.updateProfile(profile.copy(reproductiveStatus = statusJson))
}
}
fun clearProfileData(profileId: Long) {
viewModelScope.launch {
dayLogRepository.deleteAllForProfile(profileId)
cycleRepository.deleteAllForProfile(profileId)
intimacyRepository.deleteAllForProfile(profileId)
}
}
fun clearAllData() {
viewModelScope.launch {
val profiles = profileRepository.getAllProfilesOnce()
profiles.forEach { p ->
dayLogRepository.deleteAllForProfile(p.id)
cycleRepository.deleteAllForProfile(p.id)
intimacyRepository.deleteAllForProfile(p.id)
profileRepository.deleteProfile(p)
}
userPreferences.setOnboardingComplete(false)
userPreferences.setActiveProfileId(0L)
}
}
}

View File

@@ -0,0 +1,31 @@
package com.hsdiary.ui.theme
import androidx.compose.ui.graphics.Color
// Material theme seeds
val PrimaryPink = Color(0xFFE91E63)
val PrimaryPinkDark = Color(0xFFC2185B)
val OnPrimary = Color(0xFFFFFFFF)
// Cycle phase colors
val PeriodColor = Color(0xFFB71C1C)
val PeriodPredictedColor = Color(0xFFEF9A9A)
val FertileColor = Color(0xFF00796B)
val FertilePredictedColor = Color(0xFF80CBC4)
val OvulationColor = Color(0xFF00897B)
val LutealColor = Color(0xFFF57F17)
val FollicularColor = Color(0xFFE0E0E0)
// Avatar palette (8 swatches)
val AvatarColors = listOf(
Color(0xFFE91E63), // Rose
Color(0xFF9C27B0), // Purple
Color(0xFF2196F3), // Blue
Color(0xFF009688), // Teal
Color(0xFF4CAF50), // Green
Color(0xFFFF9800), // Orange
Color(0xFFFF4081), // Pink accent
Color(0xFF7C4DFF) // Violet
)
val AvatarColorLabels = listOf("Rose", "Purple", "Blue", "Teal", "Green", "Orange", "Pink", "Violet")

View File

@@ -0,0 +1,56 @@
package com.hsdiary.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme(
primary = PrimaryPink,
onPrimary = OnPrimary,
primaryContainer = Color(0xFFFCE4EC),
onPrimaryContainer = Color(0xFF880E4F),
secondary = Color(0xFF009688),
onSecondary = OnPrimary,
secondaryContainer = Color(0xFFE0F2F1),
onSecondaryContainer = Color(0xFF004D40),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFF5F5F5),
onSurfaceVariant = Color(0xFF49454F),
outline = Color(0xFF79747E)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFF48FB1),
onPrimary = Color(0xFF880E4F),
primaryContainer = Color(0xFFAD1457),
onPrimaryContainer = Color(0xFFFCE4EC),
secondary = Color(0xFF80CBC4),
onSecondary = Color(0xFF004D40),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5)
)
@Composable
fun HSDiaryTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,15 @@
package com.hsdiary.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp),
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp),
bodySmall = TextStyle(fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp),
titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp),
titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp),
labelSmall = TextStyle(fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp)
)

View File

@@ -0,0 +1,176 @@
package com.hsdiary.ui.trends
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HealthTrendsScreen(
onBack: () -> Unit,
viewModel: HealthTrendsViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Health Trends") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Range selector
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
TrendsRange.values().forEach { range ->
FilterChip(
selected = state.range == range,
onClick = { viewModel.setRange(range) },
label = {
Text(when (range) {
TrendsRange.DAYS_30 -> "30d"
TrendsRange.MONTHS_3 -> "3m"
TrendsRange.MONTHS_6 -> "6m"
TrendsRange.ALL -> "All"
}, style = MaterialTheme.typography.labelSmall)
}
)
}
}
}
// Summary header
if (state.conditionFrequencies.isEmpty()) {
item {
Box(
Modifier.fillMaxWidth().padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Text(
"No conditions logged in this range.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
item {
Text(
"Logged this period",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
items(state.conditionFrequencies) { freq ->
ConditionFrequencyCard(
frequency = freq,
isSelected = state.selectedCondition?.conditionKey == freq.definition.conditionKey,
onClick = {
viewModel.selectCondition(
if (state.selectedCondition?.conditionKey == freq.definition.conditionKey) null
else freq.definition
)
}
)
}
}
}
}
}
@Composable
private fun ConditionFrequencyCard(
frequency: ConditionFrequency,
isSelected: Boolean,
onClick: () -> Unit
) {
val barColor = MaterialTheme.colorScheme.primary
ElevatedCard(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(frequency.definition.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (frequency.isRecurring) {
Badge(containerColor = MaterialTheme.colorScheme.tertiaryContainer) {
Text("Recurring", style = MaterialTheme.typography.labelSmall)
}
}
}
Text(
"${frequency.count} times · avg rating ${"%.1f".format(frequency.avgRating)}/5",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"×${frequency.count}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
if (isSelected && frequency.weeklyData.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
Text("Weekly occurrences", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
val maxCount = (frequency.weeklyData.maxOrNull() ?: 1).toFloat().coerceAtLeast(1f)
Canvas(modifier = Modifier.fillMaxWidth().height(60.dp)) {
val barW = size.width / (frequency.weeklyData.size * 1.5f)
val gap = barW * 0.5f
frequency.weeklyData.forEachIndexed { i, count ->
val h = (count / maxCount) * size.height * 0.9f
drawRect(
color = barColor,
topLeft = Offset(i * (barW + gap), size.height - h),
size = Size(barW, h)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,130 @@
package com.hsdiary.ui.trends
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hsdiary.data.db.entity.ConditionDefinitionEntity
import com.hsdiary.data.db.entity.ConditionEntryEntity
import com.hsdiary.data.db.entity.DayLogEntity
import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.DayLogRepository
import com.hsdiary.data.repository.ProfileRepository
import com.hsdiary.domain.CyclePredictionEngine
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
enum class TrendsRange { DAYS_30, MONTHS_3, MONTHS_6, ALL }
data class ConditionFrequency(
val definition: ConditionDefinitionEntity,
val count: Int,
val isRecurring: Boolean,
val avgRating: Float,
val weeklyData: List<Int>
)
data class HealthTrendsUiState(
val profile: ProfileEntity? = null,
val range: TrendsRange = TrendsRange.DAYS_30,
val conditionFrequencies: List<ConditionFrequency> = emptyList(),
val selectedCondition: ConditionDefinitionEntity? = null,
val isLoading: Boolean = true,
val isFemale: Boolean = false
)
@HiltViewModel
class HealthTrendsViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val dayLogRepository: DayLogRepository,
private val userPreferences: UserPreferences
) : ViewModel() {
private val _range = MutableStateFlow(TrendsRange.DAYS_30)
private val _selectedCondition = MutableStateFlow<ConditionDefinitionEntity?>(null)
private val _uiState = MutableStateFlow(HealthTrendsUiState())
val uiState: StateFlow<HealthTrendsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch { load() }
}
private suspend fun load() {
val activeId = userPreferences.activeProfileId.first()
val profiles = profileRepository.getAllProfilesOnce()
val profile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() ?: return
val isFemale = profile.profileType == ProfileType.FEMALE.name
val allDefs = dayLogRepository.getAllDefinitions()
.filter { it.profileVisibility == "ALL" || (isFemale && it.profileVisibility == "FEMALE_ONLY") }
_range.combine(_selectedCondition) { range, sel -> range to sel }
.onEach { (range, sel) ->
val today = LocalDate.now()
val startDate = when (range) {
TrendsRange.DAYS_30 -> today.minusDays(29)
TrendsRange.MONTHS_3 -> today.minusMonths(3)
TrendsRange.MONTHS_6 -> today.minusMonths(6)
TrendsRange.ALL -> today.minusYears(5)
}
val entries = dayLogRepository.getConditionsInRange(
profile.id, startDate.toString(), today.toString()
)
val dayLogs = dayLogRepository.getDayLogsInRangeOnce(
profile.id, startDate.toString(), today.toString()
)
val dayLogMap = dayLogs.associateBy { it.id }
val grouped = entries.groupBy { it.conditionKey }
val frequencies = allDefs.mapNotNull { def ->
val entriesForKey = grouped[def.conditionKey] ?: return@mapNotNull null
val weeklyData = buildWeeklyData(entriesForKey, dayLogMap, startDate, today)
ConditionFrequency(
definition = def,
count = entriesForKey.size,
isRecurring = entriesForKey.size >= 3,
avgRating = entriesForKey.map { it.rating }.average().toFloat(),
weeklyData = weeklyData
)
}.sortedByDescending { it.count }
_uiState.value = HealthTrendsUiState(
profile = profile,
range = range,
conditionFrequencies = frequencies,
selectedCondition = sel,
isLoading = false,
isFemale = isFemale
)
}
.launchIn(viewModelScope)
}
private fun buildWeeklyData(
entries: List<ConditionEntryEntity>,
dayLogMap: Map<Long, DayLogEntity>,
startDate: LocalDate,
today: LocalDate
): List<Int> {
val weeks = mutableListOf<Int>()
var weekStart = startDate
while (!weekStart.isAfter(today)) {
val weekEnd = weekStart.plusDays(6)
val count = entries.count { entry ->
val log = dayLogMap[entry.dayLogId] ?: return@count false
val date = LocalDate.parse(log.date)
!date.isBefore(weekStart) && !date.isAfter(weekEnd)
}
weeks.add(count)
weekStart = weekStart.plusWeeks(1)
}
return weeks
}
fun setRange(range: TrendsRange) { _range.value = range }
fun selectCondition(def: ConditionDefinitionEntity?) { _selectedCondition.value = def }
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#FF5C8B" android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,72 C54,72 26,55 26,38 C26,29.2 33.2,23 42,23 C47.6,23 52.6,25.8 56,30.2 C59.4,25.8 64.4,23 70,23 C78.8,23 86,29.2 86,38 C86,55 58,72 58,72 L54,72Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">H&amp;S Diary</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HSDiary" parent="android:Theme.Material.Light.NoActionBar" />
</resources>