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:
7
app/src/main/java/com/hsdiary/HSDiaryApplication.kt
Normal file
7
app/src/main/java/com/hsdiary/HSDiaryApplication.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.hsdiary
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class HSDiaryApplication : Application()
|
||||
22
app/src/main/java/com/hsdiary/MainActivity.kt
Normal file
22
app/src/main/java/com/hsdiary/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
app/src/main/java/com/hsdiary/data/db/AppDatabase.kt
Normal file
133
app/src/main/java/com/hsdiary/data/db/AppDatabase.kt
Normal 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})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/hsdiary/data/db/dao/ConditionDao.kt
Normal file
33
app/src/main/java/com/hsdiary/data/db/dao/ConditionDao.kt
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
41
app/src/main/java/com/hsdiary/data/db/dao/CycleRecordDao.kt
Normal file
41
app/src/main/java/com/hsdiary/data/db/dao/CycleRecordDao.kt
Normal 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)
|
||||
}
|
||||
32
app/src/main/java/com/hsdiary/data/db/dao/DayLogDao.kt
Normal file
32
app/src/main/java/com/hsdiary/data/db/dao/DayLogDao.kt
Normal 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)
|
||||
}
|
||||
32
app/src/main/java/com/hsdiary/data/db/dao/IntimacyLogDao.kt
Normal file
32
app/src/main/java/com/hsdiary/data/db/dao/IntimacyLogDao.kt
Normal 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)
|
||||
}
|
||||
29
app/src/main/java/com/hsdiary/data/db/dao/ProfileDao.kt
Normal file
29
app/src/main/java/com/hsdiary/data/db/dao/ProfileDao.kt
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
25
app/src/main/java/com/hsdiary/data/db/entity/DayLogEntity.kt
Normal file
25
app/src/main/java/com/hsdiary/data/db/entity/DayLogEntity.kt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = "{}"
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
13
app/src/main/java/com/hsdiary/data/model/CyclePhase.kt
Normal file
13
app/src/main/java/com/hsdiary/data/model/CyclePhase.kt
Normal 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
|
||||
}
|
||||
3
app/src/main/java/com/hsdiary/data/model/ProfileType.kt
Normal file
3
app/src/main/java/com/hsdiary/data/model/ProfileType.kt
Normal file
@@ -0,0 +1,3 @@
|
||||
package com.hsdiary.data.model
|
||||
|
||||
enum class ProfileType { FEMALE, MALE }
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
24
app/src/main/java/com/hsdiary/di/AppModule.kt
Normal file
24
app/src/main/java/com/hsdiary/di/AppModule.kt
Normal 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())
|
||||
}
|
||||
32
app/src/main/java/com/hsdiary/di/DatabaseModule.kt
Normal file
32
app/src/main/java/com/hsdiary/di/DatabaseModule.kt
Normal 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()
|
||||
}
|
||||
159
app/src/main/java/com/hsdiary/domain/CyclePredictionEngine.kt
Normal file
159
app/src/main/java/com/hsdiary/domain/CyclePredictionEngine.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
330
app/src/main/java/com/hsdiary/ui/calendar/CalendarScreen.kt
Normal file
330
app/src/main/java/com/hsdiary/ui/calendar/CalendarScreen.kt
Normal 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) }
|
||||
207
app/src/main/java/com/hsdiary/ui/calendar/CalendarViewModel.kt
Normal file
207
app/src/main/java/com/hsdiary/ui/calendar/CalendarViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/com/hsdiary/ui/components/ProfileChip.kt
Normal file
77
app/src/main/java/com/hsdiary/ui/components/ProfileChip.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
516
app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt
Normal file
516
app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt
Normal 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)
|
||||
}
|
||||
258
app/src/main/java/com/hsdiary/ui/daydetail/DayDetailViewModel.kt
Normal file
258
app/src/main/java/com/hsdiary/ui/daydetail/DayDetailViewModel.kt
Normal 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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/src/main/java/com/hsdiary/ui/insights/CycleInsightsScreen.kt
Normal file
207
app/src/main/java/com/hsdiary/ui/insights/CycleInsightsScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/com/hsdiary/ui/navigation/AppNavigation.kt
Normal file
60
app/src/main/java/com/hsdiary/ui/navigation/AppNavigation.kt
Normal 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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/src/main/java/com/hsdiary/ui/navigation/Screen.kt
Normal file
12
app/src/main/java/com/hsdiary/ui/navigation/Screen.kt
Normal 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")
|
||||
}
|
||||
165
app/src/main/java/com/hsdiary/ui/onboarding/OnboardingScreen.kt
Normal file
165
app/src/main/java/com/hsdiary/ui/onboarding/OnboardingScreen.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
242
app/src/main/java/com/hsdiary/ui/settings/SettingsScreen.kt
Normal file
242
app/src/main/java/com/hsdiary/ui/settings/SettingsScreen.kt
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/com/hsdiary/ui/theme/Color.kt
Normal file
31
app/src/main/java/com/hsdiary/ui/theme/Color.kt
Normal 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")
|
||||
56
app/src/main/java/com/hsdiary/ui/theme/Theme.kt
Normal file
56
app/src/main/java/com/hsdiary/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
15
app/src/main/java/com/hsdiary/ui/theme/Type.kt
Normal file
15
app/src/main/java/com/hsdiary/ui/theme/Type.kt
Normal 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)
|
||||
)
|
||||
176
app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt
Normal file
176
app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/main/java/com/hsdiary/ui/trends/HealthTrendsViewModel.kt
Normal file
130
app/src/main/java/com/hsdiary/ui/trends/HealthTrendsViewModel.kt
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user