大法师
@@ -0,0 +1,40 @@
|
|||||||
|
# Plan: 对齐 C++ C/T 线检测逻辑
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
当前 Kotlin 代码跳过条带定位步骤,直接在整张相机画面(960×1280)上用极低阈值检测 C/T 线,导致噪声轮廓大量通过,C 线检测不稳定。
|
||||||
|
|
||||||
|
C++ 方案正确:**先找绿色定位块 → 裁剪旋转条带区域 → 在裁剪后的条带上检测 C/T 线**。
|
||||||
|
|
||||||
|
## 修改范围
|
||||||
|
|
||||||
|
### 1. `StripLocator.kt` — `locate()` 函数(主路径)
|
||||||
|
|
||||||
|
**a) 放宽 center distance ratio(第 408 行)**
|
||||||
|
- `1.65` → `1.7`(对齐 C++)
|
||||||
|
|
||||||
|
**b) C/T 线过滤顺序和阈值(第 542-548 行)**
|
||||||
|
- 当前:Width(0.03~0.25) → Height(0.4~1.1)
|
||||||
|
- 改为:Height(0.7~1.1) → Width(0.045~0.20)(对齐 C++)
|
||||||
|
|
||||||
|
**c) 去重逻辑(第 556-569 行)**
|
||||||
|
- 当前:保留离图像中心最近的 2 个轮廓
|
||||||
|
- 改为:左右两侧各保留 1 个(最靠近中心线的),对齐 C++
|
||||||
|
|
||||||
|
### 2. `StripLocator.kt` — `locateCroppedStrip()` 函数(回退路径)
|
||||||
|
|
||||||
|
**a) 过滤顺序和阈值(第 850-856 行)**
|
||||||
|
- 当前:Width(0.005~0.35) → Height(0.02~1.1)
|
||||||
|
- 改为:Height(0.15~1.1) → Width(0.01~0.25)
|
||||||
|
|
||||||
|
**b) 去重逻辑(第 864-879 行)**
|
||||||
|
- 同 `locate()`,改为左右各保留 1 个
|
||||||
|
|
||||||
|
### 3. `ScanViewModel.kt` — `processFrameInternal()`(第 161-166 行)
|
||||||
|
|
||||||
|
- 当前:直接调用 `locateCroppedStrip(rgbMat)`
|
||||||
|
- 改为:先 try `locate(srcRgba, roiRect, false)`,失败再 fallback 到 `locateCroppedStrip`
|
||||||
|
|
||||||
|
## 预期效果
|
||||||
|
- C/T 线检测在裁剪后的条带区域上进行,过滤阈值合理
|
||||||
|
- C 线检测更稳定,减少"只检测到 1 条线"的情况
|
||||||
|
- 即使 `locate()` 失败,fallback 路径也有改善的阈值
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/library" />
|
||||||
<option value="$PROJECT_DIR$/openCVLibrary300" />
|
<option value="$PROJECT_DIR$/openCVLibrary300" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
@@ -59,6 +60,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
implementation(project(":library"))
|
||||||
implementation(project(":openCVLibrary300"))
|
implementation(project(":openCVLibrary300"))
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ fun PinkOVApp() {
|
|||||||
AppDestinations.HOME -> HomePage(
|
AppDestinations.HOME -> HomePage(
|
||||||
onScanClick = { showScanPage = true }
|
onScanClick = { showScanPage = true }
|
||||||
)
|
)
|
||||||
AppDestinations.RECORDS -> RecordsPage()
|
AppDestinations.RECORDS -> RecordsPage(
|
||||||
|
onScanClick = { showScanPage = true }
|
||||||
|
)
|
||||||
AppDestinations.MINE -> MinePage()
|
AppDestinations.MINE -> MinePage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import com.pinkbear.pinkov.data.entity.ScanRecord
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ScanRecord::class, PeriodSettings::class, DailyRecord::class],
|
entities = [ScanRecord::class, PeriodSettings::class, DailyRecord::class],
|
||||||
version = 2,
|
version = 4,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -56,6 +56,78 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// Recreate daily_records to replace follicle_size/follicle_time with
|
||||||
|
// the 4 new follicle_left_a/b / follicle_right_a/b columns.
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS `daily_records_new` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`date` INTEGER NOT NULL,
|
||||||
|
`is_period_start` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`had_sex` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`mood` TEXT,
|
||||||
|
`discharge` TEXT,
|
||||||
|
`note` TEXT,
|
||||||
|
`temperature` REAL,
|
||||||
|
`temperature_time` INTEGER,
|
||||||
|
`sex_method` TEXT,
|
||||||
|
`sex_time` INTEGER,
|
||||||
|
`symptoms` TEXT,
|
||||||
|
`supplements` TEXT,
|
||||||
|
`weight` REAL,
|
||||||
|
`weight_time` INTEGER,
|
||||||
|
`habits` TEXT,
|
||||||
|
`stool_type` TEXT,
|
||||||
|
`plan` TEXT,
|
||||||
|
`follicle_left_a` INTEGER,
|
||||||
|
`follicle_left_b` INTEGER,
|
||||||
|
`follicle_right_a` INTEGER,
|
||||||
|
`follicle_right_b` INTEGER
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
// Copy existing data (old follicle_size/follicle_time are dropped)
|
||||||
|
db.execSQL("""
|
||||||
|
INSERT INTO `daily_records_new` (
|
||||||
|
`id`, `date`, `is_period_start`, `had_sex`, `mood`, `discharge`, `note`,
|
||||||
|
`temperature`, `temperature_time`, `sex_method`, `sex_time`,
|
||||||
|
`symptoms`, `supplements`, `weight`, `weight_time`, `habits`,
|
||||||
|
`stool_type`, `plan`
|
||||||
|
) SELECT
|
||||||
|
`id`, `date`, `is_period_start`, `had_sex`, `mood`, `discharge`, `note`,
|
||||||
|
`temperature`, `temperature_time`, `sex_method`, `sex_time`,
|
||||||
|
`symptoms`, `supplements`, `weight`, `weight_time`, `habits`,
|
||||||
|
`stool_type`, `plan`
|
||||||
|
FROM `daily_records`
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("DROP TABLE `daily_records`")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records_new` RENAME TO `daily_records`")
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS `index_daily_records_date` ON `daily_records` (`date`)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `temperature` REAL DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `temperature_time` INTEGER DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `sex_method` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `sex_time` INTEGER DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `follicle_size` REAL DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `follicle_time` INTEGER DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `symptoms` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `supplements` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `weight` REAL DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `weight_time` INTEGER DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `habits` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `stool_type` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE `daily_records` ADD COLUMN `plan` TEXT DEFAULT NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase {
|
fun getInstance(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -63,7 +135,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"pinkov_database"
|
"pinkov_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,91 @@ data class DailyRecord(
|
|||||||
@ColumnInfo(name = "is_period_start")
|
@ColumnInfo(name = "is_period_start")
|
||||||
val isPeriodStart: Boolean = false,
|
val isPeriodStart: Boolean = false,
|
||||||
|
|
||||||
|
// ---- 基础体温 ----
|
||||||
|
/** Basal body temperature in Celsius. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "temperature")
|
||||||
|
val temperature: Float? = null,
|
||||||
|
|
||||||
|
/** Time of temperature measurement (epoch millis). */
|
||||||
|
@ColumnInfo(name = "temperature_time")
|
||||||
|
val temperatureTime: Long? = null,
|
||||||
|
|
||||||
|
// ---- 爱爱 ----
|
||||||
/** User recorded sexual activity on this date. */
|
/** User recorded sexual activity on this date. */
|
||||||
@ColumnInfo(name = "had_sex")
|
@ColumnInfo(name = "had_sex")
|
||||||
val hadSex: Boolean = false,
|
val hadSex: Boolean = false,
|
||||||
|
|
||||||
/** Mood tag (e.g. 焦虑, 期待, 低落, 放松). Null = not recorded. */
|
/** Sex method (e.g. 无措施, 避孕套, 体外排精…). Null = not recorded. */
|
||||||
@ColumnInfo(name = "mood")
|
@ColumnInfo(name = "sex_method")
|
||||||
val mood: String? = null,
|
val sexMethod: String? = null,
|
||||||
|
|
||||||
/** Cervical mucus / discharge description. Null = not recorded. */
|
/** Time of sexual activity (epoch millis). */
|
||||||
|
@ColumnInfo(name = "sex_time")
|
||||||
|
val sexTime: Long? = null,
|
||||||
|
|
||||||
|
// ---- 白带 ----
|
||||||
|
/** Cervical mucus / discharge type. Null = not recorded. */
|
||||||
@ColumnInfo(name = "discharge")
|
@ColumnInfo(name = "discharge")
|
||||||
val discharge: String? = null,
|
val discharge: String? = null,
|
||||||
|
|
||||||
/** Free-text notes for this date. */
|
// ---- 卵泡监测 ----
|
||||||
|
/** Left follicle width in mm. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "follicle_left_a")
|
||||||
|
val follicleLeftA: Int? = null,
|
||||||
|
|
||||||
|
/** Left follicle height in mm. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "follicle_left_b")
|
||||||
|
val follicleLeftB: Int? = null,
|
||||||
|
|
||||||
|
/** Right follicle width in mm. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "follicle_right_a")
|
||||||
|
val follicleRightA: Int? = null,
|
||||||
|
|
||||||
|
/** Right follicle height in mm. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "follicle_right_b")
|
||||||
|
val follicleRightB: Int? = null,
|
||||||
|
|
||||||
|
// ---- 症状 ----
|
||||||
|
/** Symptom tags, comma-separated. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "symptoms")
|
||||||
|
val symptoms: String? = null,
|
||||||
|
|
||||||
|
// ---- 心情 ----
|
||||||
|
/** Mood tag (e.g. 超开心, 焦虑, 低落, 放松). Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "mood")
|
||||||
|
val mood: String? = null,
|
||||||
|
|
||||||
|
// ---- 营养补充 ----
|
||||||
|
/** Supplement tags, comma-separated. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "supplements")
|
||||||
|
val supplements: String? = null,
|
||||||
|
|
||||||
|
// ---- 体重 ----
|
||||||
|
/** Body weight in kg. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "weight")
|
||||||
|
val weight: Float? = null,
|
||||||
|
|
||||||
|
/** Time of weight measurement (epoch millis). */
|
||||||
|
@ColumnInfo(name = "weight_time")
|
||||||
|
val weightTime: Long? = null,
|
||||||
|
|
||||||
|
// ---- 日记 ----
|
||||||
|
/** Free-text diary entry for this date. */
|
||||||
@ColumnInfo(name = "note")
|
@ColumnInfo(name = "note")
|
||||||
val note: String? = null,
|
val note: String? = null,
|
||||||
|
|
||||||
|
// ---- 好习惯 ----
|
||||||
|
/** Habit tags, comma-separated. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "habits")
|
||||||
|
val habits: String? = null,
|
||||||
|
|
||||||
|
// ---- 便便 ----
|
||||||
|
/** Stool type (e.g. 正常, 稀便, 干便…). Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "stool_type")
|
||||||
|
val stoolType: String? = null,
|
||||||
|
|
||||||
|
// ---- 计划 ----
|
||||||
|
/** Plan text for this date. Null = not recorded. */
|
||||||
|
@ColumnInfo(name = "plan")
|
||||||
|
val plan: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,42 +1,84 @@
|
|||||||
package com.pinkbear.pinkov.ui.component
|
package com.pinkbear.pinkov.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.pinkbear.pinkov.R
|
||||||
import com.pinkbear.pinkov.data.entity.DailyRecord
|
import com.pinkbear.pinkov.data.entity.DailyRecord
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.DiarySheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.DischargeSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.FollicleSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.HabitSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.LoveSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.MoodSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.PlanSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.StoolSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.SupplementSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.SymptomSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.TemperatureSheet
|
||||||
|
import com.pinkbear.pinkov.ui.component.checkin.WeightSheet
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which check-in bottom sheet is currently open.
|
||||||
|
*/
|
||||||
|
private enum class CheckInSheet {
|
||||||
|
TEMPERATURE, LOVE, DISCHARGE, FOLLICLE,
|
||||||
|
SYMPTOM, MOOD, SUPPLEMENT, WEIGHT,
|
||||||
|
DIARY, HABIT, STOOL, PLAN
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom panel showing check-in items for the selected calendar date.
|
* Bottom panel showing check-in items for the selected calendar date.
|
||||||
*
|
|
||||||
* When no date is selected, shows a hint. When a date is selected,
|
|
||||||
* shows the "月经来了" switch and (in future) more check-in items.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CheckInPanel(
|
fun CheckInPanel(
|
||||||
selectedDate: LocalDate?,
|
selectedDate: LocalDate?,
|
||||||
record: DailyRecord?,
|
record: DailyRecord?,
|
||||||
onPeriodStartChanged: (LocalDate, Boolean) -> Unit,
|
onPeriodStartChanged: (LocalDate, Boolean) -> Unit,
|
||||||
|
onScanClick: () -> Unit,
|
||||||
|
onTemperatureSave: (LocalDate, Float, Long) -> Unit,
|
||||||
|
onLoveSave: (LocalDate, String, Long) -> Unit,
|
||||||
|
onDischargeSave: (LocalDate, String) -> Unit,
|
||||||
|
onFollicleSave: (LocalDate, Int, Int, Int, Int) -> Unit,
|
||||||
|
onSymptomSave: (LocalDate, String) -> Unit,
|
||||||
|
onMoodSave: (LocalDate, String) -> Unit,
|
||||||
|
onSupplementSave: (LocalDate, String) -> Unit,
|
||||||
|
onWeightSave: (LocalDate, Float, Long) -> Unit,
|
||||||
|
onDiarySave: (LocalDate, String) -> Unit,
|
||||||
|
onHabitSave: (LocalDate, String) -> Unit,
|
||||||
|
onStoolSave: (LocalDate, String) -> Unit,
|
||||||
|
onPlanSave: (LocalDate, String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE)
|
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE)
|
||||||
|
var openSheet by remember { mutableStateOf<CheckInSheet?>(null) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -69,25 +111,233 @@ fun CheckInPanel(
|
|||||||
onPeriodStartChanged(selectedDate, checked)
|
onPeriodStartChanged(selectedDate, checked)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
thickness = 0.5.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Placeholder items (future) ----
|
// ---- 13 check-in entries ----
|
||||||
PlaceholderCheckInRow(label = "爱爱")
|
// 1. 排卵试纸 → navigate to scan
|
||||||
HorizontalDivider(
|
CheckInEntryRow(
|
||||||
thickness = 0.5.dp,
|
iconRes = R.drawable.record_icon_pailuan,
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
label = "排卵试纸",
|
||||||
|
valueText = null, // never shows saved value, always navigates
|
||||||
|
onClick = onScanClick,
|
||||||
)
|
)
|
||||||
PlaceholderCheckInRow(label = "心情")
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 0.5.dp,
|
// 2. 基础体温
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_tiwen_no,
|
||||||
|
label = "基础体温",
|
||||||
|
valueText = record?.temperature?.let { "${it}°C" },
|
||||||
|
onClick = { openSheet = CheckInSheet.TEMPERATURE },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 3. 爱爱
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_aiai,
|
||||||
|
label = "爱爱",
|
||||||
|
valueText = record?.sexMethod,
|
||||||
|
onClick = { openSheet = CheckInSheet.LOVE },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 4. 白带
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_baidai,
|
||||||
|
label = "白带",
|
||||||
|
valueText = record?.discharge,
|
||||||
|
onClick = { openSheet = CheckInSheet.DISCHARGE },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 5. 卵泡监测
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_pailuan,
|
||||||
|
label = "卵泡监测",
|
||||||
|
valueText = buildFollicleDisplay(record),
|
||||||
|
onClick = { openSheet = CheckInSheet.FOLLICLE },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 6. 症状
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_ill,
|
||||||
|
label = "症状",
|
||||||
|
valueText = record?.symptoms,
|
||||||
|
onClick = { openSheet = CheckInSheet.SYMPTOM },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 7. 心情
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_xinqing,
|
||||||
|
label = "心情",
|
||||||
|
valueText = record?.mood,
|
||||||
|
onClick = { openSheet = CheckInSheet.MOOD },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 8. 营养补充
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_help,
|
||||||
|
label = "营养补充",
|
||||||
|
valueText = record?.supplements,
|
||||||
|
onClick = { openSheet = CheckInSheet.SUPPLEMENT },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 9. 体重
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_tizhong_no,
|
||||||
|
label = "体重",
|
||||||
|
valueText = record?.weight?.let { "${it}kg" },
|
||||||
|
onClick = { openSheet = CheckInSheet.WEIGHT },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 10. 日记
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_riji,
|
||||||
|
label = "日记",
|
||||||
|
valueText = record?.note,
|
||||||
|
onClick = { openSheet = CheckInSheet.DIARY },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 11. 好习惯
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_habite,
|
||||||
|
label = "好习惯",
|
||||||
|
valueText = record?.habits,
|
||||||
|
onClick = { openSheet = CheckInSheet.HABIT },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 12. 便便
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_sleep,
|
||||||
|
label = "便便",
|
||||||
|
valueText = record?.stoolType,
|
||||||
|
onClick = { openSheet = CheckInSheet.STOOL },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
|
// 13. 计划
|
||||||
|
CheckInEntryRow(
|
||||||
|
iconRes = R.drawable.record_icon_tool,
|
||||||
|
label = "计划",
|
||||||
|
valueText = record?.plan,
|
||||||
|
onClick = { openSheet = CheckInSheet.PLAN },
|
||||||
)
|
)
|
||||||
PlaceholderCheckInRow(label = "白带")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Bottom Sheets ----
|
||||||
|
when (openSheet) {
|
||||||
|
CheckInSheet.TEMPERATURE -> TemperatureSheet(
|
||||||
|
initialTemp = record?.temperature,
|
||||||
|
initialTime = record?.temperatureTime,
|
||||||
|
onSave = { temp, time ->
|
||||||
|
selectedDate?.let { onTemperatureSave(it, temp, time) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.LOVE -> LoveSheet(
|
||||||
|
initialMethod = record?.sexMethod,
|
||||||
|
initialTime = record?.sexTime,
|
||||||
|
onSave = { method, time ->
|
||||||
|
selectedDate?.let { onLoveSave(it, method, time) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.DISCHARGE -> DischargeSheet(
|
||||||
|
initialType = record?.discharge,
|
||||||
|
onSave = { type ->
|
||||||
|
selectedDate?.let { onDischargeSave(it, type) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.FOLLICLE -> FollicleSheet(
|
||||||
|
initialLeftA = record?.follicleLeftA,
|
||||||
|
initialLeftB = record?.follicleLeftB,
|
||||||
|
initialRightA = record?.follicleRightA,
|
||||||
|
initialRightB = record?.follicleRightB,
|
||||||
|
onSave = { leftA, leftB, rightA, rightB ->
|
||||||
|
selectedDate?.let { onFollicleSave(it, leftA, leftB, rightA, rightB) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.SYMPTOM -> SymptomSheet(
|
||||||
|
initialSymptoms = record?.symptoms,
|
||||||
|
onSave = { symptoms ->
|
||||||
|
selectedDate?.let { onSymptomSave(it, symptoms) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.MOOD -> MoodSheet(
|
||||||
|
initialMood = record?.mood,
|
||||||
|
onSave = { mood ->
|
||||||
|
selectedDate?.let { onMoodSave(it, mood) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.SUPPLEMENT -> SupplementSheet(
|
||||||
|
initialSupplements = record?.supplements,
|
||||||
|
onSave = { supplements ->
|
||||||
|
selectedDate?.let { onSupplementSave(it, supplements) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.WEIGHT -> WeightSheet(
|
||||||
|
initialWeight = record?.weight,
|
||||||
|
initialTime = record?.weightTime,
|
||||||
|
onSave = { weight, time ->
|
||||||
|
selectedDate?.let { onWeightSave(it, weight, time) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.DIARY -> DiarySheet(
|
||||||
|
initialNote = record?.note,
|
||||||
|
onSave = { note ->
|
||||||
|
selectedDate?.let { onDiarySave(it, note) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.HABIT -> HabitSheet(
|
||||||
|
initialHabits = record?.habits,
|
||||||
|
onSave = { habits ->
|
||||||
|
selectedDate?.let { onHabitSave(it, habits) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.STOOL -> StoolSheet(
|
||||||
|
initialType = record?.stoolType,
|
||||||
|
onSave = { type ->
|
||||||
|
selectedDate?.let { onStoolSave(it, type) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
CheckInSheet.PLAN -> PlanSheet(
|
||||||
|
initialPlan = record?.plan,
|
||||||
|
onSave = { plan ->
|
||||||
|
selectedDate?.let { onPlanSave(it, plan) }
|
||||||
|
openSheet = null
|
||||||
|
},
|
||||||
|
onDismiss = { openSheet = null },
|
||||||
|
)
|
||||||
|
null -> { /* No sheet open */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -112,24 +362,71 @@ private fun CheckInSwitchRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single check-in entry row: icon | label | value/arrow.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaceholderCheckInRow(label: String) {
|
private fun CheckInEntryRow(
|
||||||
|
iconRes: Int,
|
||||||
|
label: String,
|
||||||
|
valueText: String?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clickable { onClick() }
|
||||||
.padding(vertical = 10.dp),
|
.padding(vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(iconRes),
|
||||||
|
contentDescription = label,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
if (valueText != null) {
|
||||||
text = "—",
|
Text(
|
||||||
fontSize = 15.sp,
|
text = valueText,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.record_icon_right_arrow),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build display text for follicle data matching Meiyou's format:
|
||||||
|
* "左12×10mm", "右15×13mm", or "左12×10 右15×13mm".
|
||||||
|
*/
|
||||||
|
private fun buildFollicleDisplay(record: DailyRecord?): String? {
|
||||||
|
if (record == null) return null
|
||||||
|
val left = record.follicleLeftA?.takeIf { it > 0 }
|
||||||
|
val leftB = record.follicleLeftB?.takeIf { it > 0 }
|
||||||
|
val right = record.follicleRightA?.takeIf { it > 0 }
|
||||||
|
val rightB = record.follicleRightB?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
val leftStr = if (left != null && leftB != null) "左${left}×${leftB}mm" else null
|
||||||
|
val rightStr = if (right != null && rightB != null) "右${right}×${rightB}mm" else null
|
||||||
|
|
||||||
|
return when {
|
||||||
|
leftStr != null && rightStr != null -> "$leftStr $rightStr"
|
||||||
|
leftStr != null -> leftStr
|
||||||
|
rightStr != null -> rightStr
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic bottom sheet wrapper for check-in forms.
|
||||||
|
*
|
||||||
|
* Provides a consistent [ModalBottomSheet] with skip-scrim-area behavior.
|
||||||
|
* Content is rendered via [content] slot.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CheckInBottomSheet(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiarySheet(
|
||||||
|
initialNote: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var note by remember { mutableStateOf(initialNote ?: "") }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("日记", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = note,
|
||||||
|
onValueChange = { note = it },
|
||||||
|
label = { Text("写点什么呢…") },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(160.dp),
|
||||||
|
maxLines = 8,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onSave(note) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = note.isNotBlank(),
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import com.pinkbear.pinkov.R
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the 5 leukorrhea / cervical-mucus types, matching Meiyou's data model.
|
||||||
|
*/
|
||||||
|
private data class DischargeOption(
|
||||||
|
val code: Int,
|
||||||
|
val iconRes: Int,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The 5 discharge types, kept in Meiyou's V2 dialog order. */
|
||||||
|
private val DISCHARGE_OPTIONS = listOf(
|
||||||
|
DischargeOption(3, R.drawable.icon_leukorrhea_ganzao, "干燥", "内裤干爽,分泌物量少,无水分"),
|
||||||
|
DischargeOption(2, R.drawable.icon_leukorrhea_nianchou, "粘稠", "分泌物稠厚,触感像胶状物"),
|
||||||
|
DischargeOption(4, R.drawable.icon_leukorrhea_xichou, "稀糊状", "含水量偏多偏黄色,触感像乳液"),
|
||||||
|
DischargeOption(5, R.drawable.icon_leukorrhea_shuizhuang, "水状", "呈白色或透明,触感似水滴"),
|
||||||
|
DischargeOption(1, R.drawable.icon_leukorrhea_danqing, "蛋清状", "呈透明状,两指间可拉丝"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Pink accent used for the selected ring — matches Meiyou's #ff94b8. */
|
||||||
|
private val LeukorrheaPink = Color(0xFFFF94B8)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet for recording cervical mucus / discharge type.
|
||||||
|
*
|
||||||
|
* Mimics Meiyou's [LeukorrheaAbBottomDialog] (V2): 5 icons in a horizontal row,
|
||||||
|
* each with a circular pink border when selected plus a check-mark overlay.
|
||||||
|
* Tap a selected item to deselect it (back to "unrecorded").
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DischargeSheet(
|
||||||
|
initialType: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Resolve the initial type name → code for pre-selection
|
||||||
|
val initialCode = remember(initialType) {
|
||||||
|
DISCHARGE_OPTIONS.firstOrNull { it.name == initialType }?.code
|
||||||
|
}
|
||||||
|
var selectedCode by remember { mutableStateOf(initialCode) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("白带", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ---- 5-icon horizontal row ----
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
DISCHARGE_OPTIONS.forEach { option ->
|
||||||
|
val isSelected = selectedCode == option.code
|
||||||
|
DischargeIconItem(
|
||||||
|
option = option,
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
selectedCode = if (isSelected) null else option.code
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Description for the selected type ----
|
||||||
|
val selectedDesc = DISCHARGE_OPTIONS.firstOrNull { it.code == selectedCode }?.description
|
||||||
|
if (selectedDesc != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = LeukorrheaPink.copy(alpha = 0.08f),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedDesc,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ---- Save button ----
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val name = DISCHARGE_OPTIONS.firstOrNull { it.code == selectedCode }?.name ?: ""
|
||||||
|
onSave(name)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = selectedCode != null,
|
||||||
|
) {
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single discharge icon cell: circle background (pink ring when selected),
|
||||||
|
* icon image, check overlay at bottom-right when selected, name label below.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DischargeIconItem(
|
||||||
|
option: DischargeOption,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val ringWidth = if (selected) 2.dp else 0.dp
|
||||||
|
val ringColor = if (selected) LeukorrheaPink else Color.Transparent
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(4.dp),
|
||||||
|
) {
|
||||||
|
// Circular icon with optional pink ring + check overlay
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.border(ringWidth, ringColor, CircleShape)
|
||||||
|
.padding(4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(option.iconRes),
|
||||||
|
contentDescription = option.name,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check-mark overlay at bottom-right when selected
|
||||||
|
if (selected) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.icon_leukorrhea_choose),
|
||||||
|
contentDescription = "已选中",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.align(Alignment.BottomEnd),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = option.name,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = if (selected) LeukorrheaPink else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import com.pinkbear.pinkov.R
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet for recording follicle monitoring data, matching Meiyou's
|
||||||
|
* [FollicleNumKeyboardDialog] design:
|
||||||
|
* - Two rows: left ovary (左侧卵泡大小) and right ovary (右侧卵泡大小)
|
||||||
|
* - Each row: width (A) × height (B) in mm, integer input, max 2 digits
|
||||||
|
* - No time field (Meiyou does not record time for follicle measurements)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FollicleSheet(
|
||||||
|
initialLeftA: Int?,
|
||||||
|
initialLeftB: Int?,
|
||||||
|
initialRightA: Int?,
|
||||||
|
initialRightB: Int?,
|
||||||
|
onSave: (Int, Int, Int, Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var leftA by remember { mutableStateOf(initialLeftA?.toString() ?: "") }
|
||||||
|
var leftB by remember { mutableStateOf(initialLeftB?.toString() ?: "") }
|
||||||
|
var rightA by remember { mutableStateOf(initialRightA?.toString() ?: "") }
|
||||||
|
var rightB by remember { mutableStateOf(initialRightB?.toString() ?: "") }
|
||||||
|
|
||||||
|
val hasAnyData = leftA.isNotBlank() || leftB.isNotBlank() ||
|
||||||
|
rightA.isNotBlank() || rightB.isNotBlank()
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("卵泡监测", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"建议记录最大卵泡",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ---- Left ovary row ----
|
||||||
|
FollicleSizeRow(
|
||||||
|
label = "左侧卵泡大小",
|
||||||
|
valueA = leftA,
|
||||||
|
valueB = leftB,
|
||||||
|
onValueAChange = { leftA = it.take(2) },
|
||||||
|
onValueBChange = { leftB = it.take(2) },
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ---- Right ovary row ----
|
||||||
|
FollicleSizeRow(
|
||||||
|
label = "右侧卵泡大小",
|
||||||
|
valueA = rightA,
|
||||||
|
valueB = rightB,
|
||||||
|
onValueAChange = { rightA = it.take(2) },
|
||||||
|
onValueBChange = { rightB = it.take(2) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// ---- Save button ----
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val la = leftA.toIntOrNull() ?: 0
|
||||||
|
val lb = leftB.toIntOrNull() ?: 0
|
||||||
|
val ra = rightA.toIntOrNull() ?: 0
|
||||||
|
val rb = rightB.toIntOrNull() ?: 0
|
||||||
|
onSave(la, lb, ra, rb)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = hasAnyData,
|
||||||
|
) {
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single follicle size input row: label + [EditText A] + × + [EditText B] + mm.
|
||||||
|
* Matches Meiyou's `dialog_layout_follicle_pink.xml` row layout.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FollicleSizeRow(
|
||||||
|
label: String,
|
||||||
|
valueA: String,
|
||||||
|
valueB: String,
|
||||||
|
onValueAChange: (String) -> Unit,
|
||||||
|
onValueBChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Label
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Width input (A)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = valueA,
|
||||||
|
onValueChange = onValueAChange,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.width(68.dp).height(56.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Multiply sign
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.icon_multiply),
|
||||||
|
contentDescription = "乘",
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Height input (B)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = valueB,
|
||||||
|
onValueChange = onValueBChange,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.width(68.dp).height(56.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Unit
|
||||||
|
Text(
|
||||||
|
text = "mm",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val HABIT_OPTIONS = listOf(
|
||||||
|
"早睡", "运动", "泡脚", "喝豆浆", "不熬夜",
|
||||||
|
"冥想", "瑜伽", "散步", "阅读", "喝水2L",
|
||||||
|
"不吃辣", "戒咖啡", "吃早餐", "按时吃饭", "其他",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HabitSheet(
|
||||||
|
initialHabits: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val initialSet = remember(initialHabits) {
|
||||||
|
initialHabits?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
var selected by remember { mutableStateOf(initialSet) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("好习惯", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
var rowItems = mutableListOf<String>()
|
||||||
|
HABIT_OPTIONS.forEach { item ->
|
||||||
|
rowItems.add(item)
|
||||||
|
if (rowItems.size == 3) {
|
||||||
|
CheckInTagRow(rowItems, selected) { tag ->
|
||||||
|
selected = if (tag in selected) selected - tag else selected + tag
|
||||||
|
}
|
||||||
|
rowItems = mutableListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rowItems.isNotEmpty()) {
|
||||||
|
CheckInTagRow(rowItems, selected) { tag ->
|
||||||
|
selected = if (tag in selected) selected - tag else selected + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onSave(selected.joinToString(",")) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import com.pinkbear.pinkov.R
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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 java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Love method option: icon resource + Chinese label.
|
||||||
|
*/
|
||||||
|
private data class LoveOption(
|
||||||
|
val iconRes: Int,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Map of love icon resources to Chinese labels. */
|
||||||
|
private val LOVE_OPTIONS = listOf(
|
||||||
|
LoveOption(R.drawable.icon_love_wcs, "无措施"),
|
||||||
|
LoveOption(R.drawable.icon_love_byt, "避孕套"),
|
||||||
|
LoveOption(R.drawable.icon_love_twpj, "体外排精"),
|
||||||
|
LoveOption(R.drawable.icon_love_wsj, "未射精"),
|
||||||
|
LoveOption(R.drawable.icon_love_jyh, "紧急避孕药"),
|
||||||
|
LoveOption(R.drawable.icon_love_cxby, "短效避孕药"),
|
||||||
|
LoveOption(R.drawable.icon_love_dxby, "长效避孕药"),
|
||||||
|
LoveOption(R.drawable.icon_love_jjbyy, "节育环"),
|
||||||
|
LoveOption(R.drawable.icon_love_qtyy, "其他措施"),
|
||||||
|
LoveOption(R.drawable.icon_love_ziwei, "自慰"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet for recording sexual activity method and time.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoveSheet(
|
||||||
|
initialMethod: String?,
|
||||||
|
initialTime: Long?,
|
||||||
|
onSave: (String, Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val now = remember { System.currentTimeMillis() }
|
||||||
|
var selectedMethod by remember { mutableStateOf(initialMethod) }
|
||||||
|
var time by remember { mutableStateOf(initialTime ?: now) }
|
||||||
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("同房方式", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// 2 rows × 5 columns grid of love method icons
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(5),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(LOVE_OPTIONS.size) { index ->
|
||||||
|
val option = LOVE_OPTIONS[index]
|
||||||
|
val selected = selectedMethod == option.label
|
||||||
|
LoveMethodItem(
|
||||||
|
option = option,
|
||||||
|
selected = selected,
|
||||||
|
onClick = { selectedMethod = option.label },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Time row
|
||||||
|
val timeFmt = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text("同房时间", fontSize = 14.sp)
|
||||||
|
TextButton(onClick = { showTimePicker = true }) {
|
||||||
|
Text(timeFmt.format(Date(time)), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
selectedMethod?.let { onSave(it, time) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = selectedMethod != null,
|
||||||
|
) {
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time picker dialog
|
||||||
|
if (showTimePicker) {
|
||||||
|
val tpState = rememberTimePickerState(
|
||||||
|
initialHour = (time % 86400000 / 3600000).toInt(),
|
||||||
|
initialMinute = (time % 3600000 / 60000).toInt(),
|
||||||
|
is24Hour = true,
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
title = { Text("选择同房时间") },
|
||||||
|
text = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
TimePicker(state = tpState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val cal = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, tpState.hour)
|
||||||
|
set(Calendar.MINUTE, tpState.minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
time = cal.timeInMillis
|
||||||
|
showTimePicker = false
|
||||||
|
}) { Text("确定") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showTimePicker = false }) { Text("取消") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoveMethodItem(
|
||||||
|
option: LoveOption,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val borderColor = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||||
|
val bgColor = if (selected)
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.border(2.dp, borderColor, RoundedCornerShape(8.dp))
|
||||||
|
.background(bgColor)
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(option.iconRes),
|
||||||
|
contentDescription = option.label,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = option.label,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import com.pinkbear.pinkov.R
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mood option with icon resource and label.
|
||||||
|
*/
|
||||||
|
private data class MoodOption(val iconRes: Int, val label: String)
|
||||||
|
|
||||||
|
private val MOOD_OPTIONS = listOf(
|
||||||
|
MoodOption(R.drawable.icon_feeling_1_chaokaixin, "超开心"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_2_tingkaixin, "挺开心"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_3_yiban, "一般"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_4_bukaixin, "不开心"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_5_haoshangxin, "好伤心"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_6_xingfen, "兴奋"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_7_jingxi, "惊喜"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_8_manzu, "满足"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_9_xindong, "心动"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_10_zixin, "自信"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_11_fangsong, "放松"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_12_pingjing, "平静"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_13_fanzao, "烦躁"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_14_yinu, "易怒"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_15_shengqi, "生气"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_16_jiaolv, "焦虑"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_17_neihao, "内耗"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_18_yali, "压力"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_19_haipa, "害怕"),
|
||||||
|
MoodOption(R.drawable.icon_feeling_20_lengmo, "冷漠"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet for selecting mood (single-select grid).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MoodSheet(
|
||||||
|
initialMood: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var selected by remember { mutableStateOf(initialMood) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("心情", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(5),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(280.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(MOOD_OPTIONS.size) { index ->
|
||||||
|
val option = MOOD_OPTIONS[index]
|
||||||
|
val isSelected = selected == option.label
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.border(2.dp, if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, RoundedCornerShape(8.dp))
|
||||||
|
.background(if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent)
|
||||||
|
.clickable { selected = option.label }
|
||||||
|
.padding(6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(option.iconRes),
|
||||||
|
contentDescription = option.label,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = option.label,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { selected?.let { onSave(it) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = selected != null,
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlanSheet(
|
||||||
|
initialPlan: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var plan by remember { mutableStateOf(initialPlan ?: "") }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("计划", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = plan,
|
||||||
|
onValueChange = { plan = it },
|
||||||
|
label = { Text("今天有什么计划…") },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(120.dp),
|
||||||
|
maxLines = 6,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onSave(plan) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = plan.isNotBlank(),
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StoolSheet(
|
||||||
|
initialType: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = listOf("正常", "稀便", "干便", "便秘", "腹泻", "黑便", "血便", "其他")
|
||||||
|
var selected by remember { mutableStateOf(initialType) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("便便", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
options.forEach { label ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { selected = label }
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected == label, onClick = { selected = label })
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(label, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { selected?.let { onSave(it) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = selected != null,
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val SUPPLEMENT_OPTIONS = listOf(
|
||||||
|
"叶酸", "维生素D", "维生素E", "钙片", "铁剂",
|
||||||
|
"DHA", "辅酶Q10", "维生素C", "维生素B族", "锌",
|
||||||
|
"益生菌", "蔓越莓", "月见草油", "圣洁莓", "其他",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SupplementSheet(
|
||||||
|
initialSupplements: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val initialSet = remember(initialSupplements) {
|
||||||
|
initialSupplements?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
var selected by remember { mutableStateOf(initialSet) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("营养补充", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
var rowItems = mutableListOf<String>()
|
||||||
|
SUPPLEMENT_OPTIONS.forEach { item ->
|
||||||
|
rowItems.add(item)
|
||||||
|
if (rowItems.size == 3) {
|
||||||
|
CheckInTagRow(rowItems, selected) { tag ->
|
||||||
|
selected = if (tag in selected) selected - tag else selected + tag
|
||||||
|
}
|
||||||
|
rowItems = mutableListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rowItems.isNotEmpty()) {
|
||||||
|
CheckInTagRow(rowItems, selected) { tag ->
|
||||||
|
selected = if (tag in selected) selected - tag else selected + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onSave(selected.joinToString(",")) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun CheckInTagRow(
|
||||||
|
items: List<String>,
|
||||||
|
selected: Set<String>,
|
||||||
|
onToggle: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items.forEach { label ->
|
||||||
|
val isSelected = label in selected
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
.clickable { onToggle(label) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import com.pinkbear.pinkov.R
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single symptom option with icon resource and Chinese name.
|
||||||
|
* @param id Numeric ID matching Meiyou's all_symptom_data.json.
|
||||||
|
*/
|
||||||
|
private data class SymptomOption(
|
||||||
|
val id: Int,
|
||||||
|
val iconRes: Int,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Section 1: 身体症状 (pregnancy_prepare mode) ----
|
||||||
|
private val BODY_SYMPTOMS = listOf(
|
||||||
|
SymptomOption(192, R.drawable.icon_zhengzhuang_192_meiyou, "没有症状"),
|
||||||
|
SymptomOption(11, R.drawable.icon_zhengzhuang_011_yaosuan, "腰酸"),
|
||||||
|
SymptomOption(10, R.drawable.icon_zhengzhuang_010_futong, "腹痛"),
|
||||||
|
SymptomOption(12, R.drawable.icon_zhengzhuang_012_xiaofuzhuizhang, "小腹坠胀"),
|
||||||
|
SymptomOption(4, R.drawable.icon_zhengzhuang_004_rufangzhangtong, "乳房胀痛"),
|
||||||
|
SymptomOption(1, R.drawable.icon_zhengzhuang_001_shentisuantong, "身体酸痛"),
|
||||||
|
SymptomOption(0, R.drawable.icon_zhengzhuang_000_toutong, "头痛"),
|
||||||
|
SymptomOption(3, R.drawable.icon_zhengzhuang_003_xuanyun, "眩晕"),
|
||||||
|
SymptomOption(7, R.drawable.icon_zhengzhuang_007_shimian, "失眠"),
|
||||||
|
SymptomOption(5, R.drawable.icon_zhengzhuang_005_fenci, "粉刺"),
|
||||||
|
SymptomOption(32, R.drawable.icon_zhengzhuang_032_pifuganzao, "皮肤干燥"),
|
||||||
|
SymptomOption(6, R.drawable.icon_zhengzhuang_006_shiyubuzhen, "食欲不振"),
|
||||||
|
SymptomOption(117, R.drawable.icon_zhengzhuang_117_shiyuwangsheng, "食欲旺盛"),
|
||||||
|
SymptomOption(2, R.drawable.icon_zhengzhuang_002_fuxie, "腹泻"),
|
||||||
|
SymptomOption(14, R.drawable.icon_zhengzhuang_014_bianmi, "便秘"),
|
||||||
|
SymptomOption(22, R.drawable.icon_zhengzhuang_022_pibei, "疲惫"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Section 2: 阴道分泌物 (pregnancy_prepare mode) ----
|
||||||
|
private val DISCHARGE_SYMPTOMS = listOf(
|
||||||
|
SymptomOption(96, R.drawable.icon_zhengzhuang_096_hesefenmiwu, "褐色分泌物"),
|
||||||
|
SymptomOption(33, R.drawable.icon_zhengzhuang_033_chuxue, "出血"),
|
||||||
|
SymptomOption(127, R.drawable.icon_zhengzhuang_127_youxuekuai, "有血块"),
|
||||||
|
SymptomOption(25, R.drawable.icon_zhengzhuang_025_baidaizengduo, "白带增多"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Width for each icon cell: (screen - padding - gaps) / 4. Approx for bottom sheet. */
|
||||||
|
private val CELL_WIDTH = 72.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet for recording symptoms — three sections matching the Meiyou app design:
|
||||||
|
* 1. 身体症状 (body symptoms) — 4-column icon grid
|
||||||
|
* 2. 阴道分泌物 (vaginal discharge) — 4-column icon grid
|
||||||
|
* 3. 手工输入更多症状 — free-text input for custom symptoms
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SymptomSheet(
|
||||||
|
initialSymptoms: String?,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Split saved symptoms into known names and custom text.
|
||||||
|
val knownNames = BODY_SYMPTOMS.map { it.name } + DISCHARGE_SYMPTOMS.map { it.name }
|
||||||
|
val initialParts = remember(initialSymptoms) {
|
||||||
|
initialSymptoms
|
||||||
|
?.split(",")
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
val initialSelected = remember(initialParts) {
|
||||||
|
initialParts.filter { it in knownNames }.toSet()
|
||||||
|
}
|
||||||
|
val initialCustom = remember(initialParts) {
|
||||||
|
initialParts.filter { it !in knownNames }.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected by remember { mutableStateOf(initialSelected) }
|
||||||
|
var customText by remember { mutableStateOf(initialCustom) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Text("症状", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// ===== Section 1: 身体症状 =====
|
||||||
|
SectionTitle("身体症状")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
SymptomIconGrid(
|
||||||
|
options = BODY_SYMPTOMS,
|
||||||
|
selectedNames = selected,
|
||||||
|
onToggle = { name ->
|
||||||
|
selected = if (name in selected) selected - name else selected + name
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// ===== Section 2: 阴道分泌物 =====
|
||||||
|
SectionTitle("阴道分泌物")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
SymptomIconGrid(
|
||||||
|
options = DISCHARGE_SYMPTOMS,
|
||||||
|
selectedNames = selected,
|
||||||
|
onToggle = { name ->
|
||||||
|
selected = if (name in selected) selected - name else selected + name
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// ===== Section 3: 手工输入更多症状 =====
|
||||||
|
SectionTitle("手工输入更多症状")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = customText,
|
||||||
|
onValueChange = { customText = it },
|
||||||
|
placeholder = { Text("输入其他症状,用逗号或分号分隔", fontSize = 13.sp) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
maxLines = 4,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ===== Save button =====
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val parts = selected.toList() + customText
|
||||||
|
.split(",", ",", ";", ";")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
onSave(parts.joinToString(","))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(title: String) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 4-column grid of symptom icons.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SymptomIconGrid(
|
||||||
|
options: List<SymptomOption>,
|
||||||
|
selectedNames: Set<String>,
|
||||||
|
onToggle: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
// Chunk into rows of 4
|
||||||
|
val rows = options.chunked(4)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
rows.forEach { rowItems ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
rowItems.forEach { option ->
|
||||||
|
SymptomIconItem(
|
||||||
|
option = option,
|
||||||
|
selected = option.name in selectedNames,
|
||||||
|
onClick = { onToggle(option.name) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Fill remaining slots with empty space to keep alignment
|
||||||
|
repeat(4 - rowItems.size) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SymptomIconItem(
|
||||||
|
option: SymptomOption,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val borderColor = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||||
|
val bgColor = if (selected)
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.border(2.dp, borderColor, RoundedCornerShape(8.dp))
|
||||||
|
.background(bgColor)
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(option.iconRes),
|
||||||
|
contentDescription = option.name,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = option.name,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet for recording basal body temperature.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TemperatureSheet(
|
||||||
|
initialTemp: Float?,
|
||||||
|
initialTime: Long?,
|
||||||
|
onSave: (Float, Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val now = remember { System.currentTimeMillis() }
|
||||||
|
var temp by remember { mutableStateOf(initialTemp?.toString() ?: "") }
|
||||||
|
var time by remember { mutableStateOf(initialTime ?: now) }
|
||||||
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("基础体温", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Temperature input
|
||||||
|
OutlinedTextField(
|
||||||
|
value = temp,
|
||||||
|
onValueChange = { temp = it },
|
||||||
|
label = { Text("体温") },
|
||||||
|
suffix = { Text("°C") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Time display + picker button
|
||||||
|
val timeFmt = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text("测温时间", fontSize = 14.sp)
|
||||||
|
TextButton(onClick = { showTimePicker = true }) {
|
||||||
|
Text(timeFmt.format(Date(time)), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val t = temp.toFloatOrNull() ?: return@Button
|
||||||
|
onSave(t, time)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = temp.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time picker dialog
|
||||||
|
if (showTimePicker) {
|
||||||
|
val tpState = rememberTimePickerState(
|
||||||
|
initialHour = (time % 86400000 / 3600000).toInt(),
|
||||||
|
initialMinute = (time % 3600000 / 60000).toInt(),
|
||||||
|
is24Hour = true,
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
title = { Text("选择测温时间") },
|
||||||
|
text = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
TimePicker(state = tpState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val cal = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, tpState.hour)
|
||||||
|
set(Calendar.MINUTE, tpState.minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
time = cal.timeInMillis
|
||||||
|
showTimePicker = false
|
||||||
|
}) { Text("确定") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showTimePicker = false }) { Text("取消") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.pinkbear.pinkov.ui.component.checkin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WeightSheet(
|
||||||
|
initialWeight: Float?,
|
||||||
|
initialTime: Long?,
|
||||||
|
onSave: (Float, Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val now = remember { System.currentTimeMillis() }
|
||||||
|
var weightStr by remember { mutableStateOf(initialWeight?.toString() ?: "") }
|
||||||
|
var time by remember { mutableStateOf(initialTime ?: now) }
|
||||||
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CheckInBottomSheet(onDismiss = onDismiss) {
|
||||||
|
Text("体重", fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weightStr,
|
||||||
|
onValueChange = { weightStr = it },
|
||||||
|
label = { Text("体重") },
|
||||||
|
suffix = { Text("kg") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
val timeFmt = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text("测量时间", fontSize = 14.sp)
|
||||||
|
TextButton(onClick = { showTimePicker = true }) {
|
||||||
|
Text(timeFmt.format(Date(time)), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { weightStr.toFloatOrNull()?.let { onSave(it, time) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = weightStr.isNotBlank(),
|
||||||
|
) { Text("保存") }
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTimePicker) {
|
||||||
|
val tpState = rememberTimePickerState(
|
||||||
|
initialHour = (time % 86400000 / 3600000).toInt(),
|
||||||
|
initialMinute = (time % 3600000 / 60000).toInt(),
|
||||||
|
is24Hour = true,
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
title = { Text("选择测量时间") },
|
||||||
|
text = { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { TimePicker(state = tpState) } },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val cal = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, tpState.hour)
|
||||||
|
set(Calendar.MINUTE, tpState.minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
time = cal.timeInMillis
|
||||||
|
showTimePicker = false
|
||||||
|
}) { Text("确定") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { showTimePicker = false }) { Text("取消") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,10 +118,13 @@ fun HomePage(
|
|||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Black.copy(alpha = 0.03f),
|
|
||||||
Color.Black.copy(alpha = 0.08f),
|
|
||||||
Color.Black.copy(alpha = 0.14f),
|
Color.Black.copy(alpha = 0.14f),
|
||||||
)
|
|
||||||
|
Color.Black.copy(alpha = 0.08f),
|
||||||
|
|
||||||
|
Color.Black.copy(alpha = 0.03f),
|
||||||
|
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -133,7 +136,7 @@ fun HomePage(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -147,7 +150,7 @@ fun HomePage(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -199,7 +202,7 @@ private fun RecordHeaderRow() {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.padding(vertical = 8.dp, horizontal = 16.dp),
|
.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -264,7 +267,7 @@ private fun RecordRow(
|
|||||||
"阴性" -> Color(0xFF4CAF50)
|
"阴性" -> Color(0xFF4CAF50)
|
||||||
"阳性" -> Color(0xFFFF9800)
|
"阳性" -> Color(0xFFFF9800)
|
||||||
"强阳" -> Color(0xFFF44336)
|
"强阳" -> Color(0xFFF44336)
|
||||||
else -> Color.White
|
else -> MaterialTheme.colorScheme.surface
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
@@ -82,65 +83,75 @@ fun MinePage(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(
|
// HorizontalDivider(
|
||||||
thickness = 1.dp,
|
// thickness = 1.dp,
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
// color = MaterialTheme.colorScheme.outlineVariant
|
||||||
)
|
// )
|
||||||
|
|
||||||
// ---- Bottom ~80%: Menu items ----
|
// ---- Menu items ----
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.padding(top = 16.dp)
|
||||||
) {
|
) {
|
||||||
MenuItemRow(
|
// Group 1: 关于我们, 版本更新, 经期设置
|
||||||
icon = Icons.Default.Info,
|
Card(
|
||||||
title = "关于我们",
|
modifier = Modifier
|
||||||
onClick = { /* TODO: navigate to about page */ }
|
.fillMaxWidth()
|
||||||
)
|
.padding(horizontal = 16.dp),
|
||||||
HorizontalDivider(
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
thickness = 0.5.dp,
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
)
|
) {
|
||||||
|
Column {
|
||||||
|
MenuItemRow(
|
||||||
|
icon = Icons.Default.Info,
|
||||||
|
title = "关于我们",
|
||||||
|
onClick = { /* TODO: navigate to about page */ }
|
||||||
|
)
|
||||||
|
MenuItemRow(
|
||||||
|
icon = Icons.Default.Refresh,
|
||||||
|
title = "版本更新",
|
||||||
|
onClick = { /* TODO: check for updates */ }
|
||||||
|
)
|
||||||
|
MenuItemRow(
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
title = "经期设置",
|
||||||
|
onClick = { showPeriodSettings = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MenuItemRow(
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
icon = Icons.Default.Refresh,
|
|
||||||
title = "版本更新",
|
|
||||||
onClick = { /* TODO: check for updates */ }
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 0.5.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- 经期设置 (above 设置) ----
|
// Group 2: 设置
|
||||||
MenuItemRow(
|
Card(
|
||||||
icon = Icons.Default.Favorite,
|
modifier = Modifier
|
||||||
title = "经期设置",
|
.fillMaxWidth()
|
||||||
onClick = { showPeriodSettings = true }
|
.padding(horizontal = 16.dp),
|
||||||
)
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
HorizontalDivider(
|
shape = RoundedCornerShape(12.dp),
|
||||||
thickness = 0.5.dp,
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
) {
|
||||||
)
|
MenuItemRow(
|
||||||
|
icon = Icons.Default.Settings,
|
||||||
MenuItemRow(
|
title = "设置",
|
||||||
icon = Icons.Default.Settings,
|
onClick = { /* TODO: navigate to settings page */ }
|
||||||
title = "设置",
|
)
|
||||||
onClick = { /* TODO: navigate to settings page */ }
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// Push version text to bottom
|
// Push version text to bottom
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
Text(
|
// Text(
|
||||||
text = "v1.0.0",
|
// text = "v1.0.0",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
// color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontSize = 12.sp,
|
// fontSize = 12.sp,
|
||||||
modifier = Modifier
|
// modifier = Modifier
|
||||||
.align(Alignment.CenterHorizontally)
|
// .align(Alignment.CenterHorizontally)
|
||||||
.padding(bottom = 16.dp)
|
// .padding(bottom = 16.dp)
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +189,7 @@ private fun MenuItemRow(
|
|||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -45,6 +47,7 @@ import java.time.LocalDate
|
|||||||
@Composable
|
@Composable
|
||||||
fun RecordsPage(
|
fun RecordsPage(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onScanClick: () -> Unit = {},
|
||||||
recordsViewModel: RecordsViewModel = viewModel(),
|
recordsViewModel: RecordsViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val calendarState = rememberSelectableCalendarState(
|
val calendarState = rememberSelectableCalendarState(
|
||||||
@@ -77,8 +80,9 @@ fun RecordsPage(
|
|||||||
Scaffold(modifier = modifier) { innerPadding ->
|
Scaffold(modifier = modifier) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
// Calendar section
|
// Calendar section
|
||||||
SelectableCalendar(
|
SelectableCalendar(
|
||||||
@@ -114,6 +118,43 @@ fun RecordsPage(
|
|||||||
onPeriodStartChanged = { date, isStart ->
|
onPeriodStartChanged = { date, isStart ->
|
||||||
recordsViewModel.setPeriodStart(date, isStart)
|
recordsViewModel.setPeriodStart(date, isStart)
|
||||||
},
|
},
|
||||||
|
onScanClick = onScanClick,
|
||||||
|
onTemperatureSave = { date, temp, time ->
|
||||||
|
recordsViewModel.updateTemperature(date, temp, time)
|
||||||
|
},
|
||||||
|
onLoveSave = { date, method, time ->
|
||||||
|
recordsViewModel.updateSexMethod(date, method, time)
|
||||||
|
},
|
||||||
|
onDischargeSave = { date, type ->
|
||||||
|
recordsViewModel.updateDischarge(date, type)
|
||||||
|
},
|
||||||
|
onFollicleSave = { date, leftA, leftB, rightA, rightB ->
|
||||||
|
recordsViewModel.updateFollicle(date, leftA, leftB, rightA, rightB)
|
||||||
|
},
|
||||||
|
onSymptomSave = { date, symptoms ->
|
||||||
|
recordsViewModel.updateSymptoms(date, symptoms)
|
||||||
|
},
|
||||||
|
onMoodSave = { date, mood ->
|
||||||
|
recordsViewModel.updateMood(date, mood)
|
||||||
|
},
|
||||||
|
onSupplementSave = { date, supplements ->
|
||||||
|
recordsViewModel.updateSupplements(date, supplements)
|
||||||
|
},
|
||||||
|
onWeightSave = { date, weight, time ->
|
||||||
|
recordsViewModel.updateWeight(date, weight, time)
|
||||||
|
},
|
||||||
|
onDiarySave = { date, note ->
|
||||||
|
recordsViewModel.updateDiary(date, note)
|
||||||
|
},
|
||||||
|
onHabitSave = { date, habits ->
|
||||||
|
recordsViewModel.updateHabits(date, habits)
|
||||||
|
},
|
||||||
|
onStoolSave = { date, type ->
|
||||||
|
recordsViewModel.updateStool(date, type)
|
||||||
|
},
|
||||||
|
onPlanSave = { date, plan ->
|
||||||
|
recordsViewModel.updatePlan(date, plan)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,134 @@ class RecordsViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Check-in update methods ----
|
||||||
|
|
||||||
|
/** Update basal body temperature for a date. */
|
||||||
|
fun updateTemperature(date: LocalDate, temp: Float, time: Long) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(temperature = temp, temperatureTime = time)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), temperature = temp, temperatureTime = time)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update sex method and time for a date. */
|
||||||
|
fun updateSexMethod(date: LocalDate, method: String, time: Long) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(hadSex = true, sexMethod = method, sexTime = time)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), hadSex = true, sexMethod = method, sexTime = time)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update discharge type for a date. */
|
||||||
|
fun updateDischarge(date: LocalDate, type: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(discharge = type)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), discharge = type)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update follicle monitoring for a date (left/right ovary, width×height in mm). */
|
||||||
|
fun updateFollicle(date: LocalDate, leftA: Int, leftB: Int, rightA: Int, rightB: Int) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(
|
||||||
|
follicleLeftA = leftA, follicleLeftB = leftB,
|
||||||
|
follicleRightA = rightA, follicleRightB = rightB,
|
||||||
|
) ?: DailyRecord(
|
||||||
|
date = date.toEpochDay(),
|
||||||
|
follicleLeftA = leftA, follicleLeftB = leftB,
|
||||||
|
follicleRightA = rightA, follicleRightB = rightB,
|
||||||
|
)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update symptoms for a date (comma-separated). */
|
||||||
|
fun updateSymptoms(date: LocalDate, symptoms: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(symptoms = symptoms)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), symptoms = symptoms)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update mood for a date. */
|
||||||
|
fun updateMood(date: LocalDate, mood: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(mood = mood)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), mood = mood)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update supplements for a date (comma-separated). */
|
||||||
|
fun updateSupplements(date: LocalDate, supplements: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(supplements = supplements)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), supplements = supplements)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update weight for a date. */
|
||||||
|
fun updateWeight(date: LocalDate, weight: Float, time: Long) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(weight = weight, weightTime = time)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), weight = weight, weightTime = time)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update diary note for a date. */
|
||||||
|
fun updateDiary(date: LocalDate, note: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(note = note)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), note = note)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update habits for a date (comma-separated). */
|
||||||
|
fun updateHabits(date: LocalDate, habits: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(habits = habits)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), habits = habits)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update stool type for a date. */
|
||||||
|
fun updateStool(date: LocalDate, type: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(stoolType = type)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), stoolType = type)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update plan text for a date. */
|
||||||
|
fun updatePlan(date: LocalDate, plan: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val existing = recordDao.getRecordOnce(date.toEpochDay())
|
||||||
|
val record = existing?.copy(plan = plan)
|
||||||
|
?: DailyRecord(date = date.toEpochDay(), plan = plan)
|
||||||
|
recordDao.upsert(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove all period-start flags within the same month as [date], except [date] itself. */
|
/** Remove all period-start flags within the same month as [date], except [date] itself. */
|
||||||
private suspend fun clearPeriodStartsInMonth(date: LocalDate) {
|
private suspend fun clearPeriodStartsInMonth(date: LocalDate) {
|
||||||
val monthStart = date.withDayOfMonth(1)
|
val monthStart = date.withDayOfMonth(1)
|
||||||
|
|||||||
@@ -1,170 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<!-- Radial pink gradient matching theme primary: #FF8BBC → #FFC1D4 -->
|
||||||
android:fillColor="#3DDC84"
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<aapt:attr name="android:fillColor">
|
||||||
<path
|
<gradient
|
||||||
android:fillColor="#00000000"
|
android:centerX="54"
|
||||||
android:pathData="M9,0L9,108"
|
android:centerY="54"
|
||||||
android:strokeWidth="0.8"
|
android:gradientRadius="76"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:type="radial">
|
||||||
<path
|
<item
|
||||||
android:fillColor="#00000000"
|
android:color="#FFFFC1D4"
|
||||||
android:pathData="M19,0L19,108"
|
android:offset="0.0" />
|
||||||
android:strokeWidth="0.8"
|
<item
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:color="#FFFF8BBC"
|
||||||
<path
|
android:offset="1.0" />
|
||||||
android:fillColor="#00000000"
|
</gradient>
|
||||||
android:pathData="M29,0L29,108"
|
</aapt:attr>
|
||||||
android:strokeWidth="0.8"
|
</path>
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
<!-- Left ear -->
|
||||||
<gradient
|
|
||||||
android:endX="85.84757"
|
|
||||||
android:endY="92.4963"
|
|
||||||
android:startX="42.9492"
|
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:fillType="nonZero"
|
android:pathData="M24,32 A10,10 0 1,1 44,32 A10,10 0 1,1 24,32 Z" />
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
|
||||||
android:strokeWidth="1"
|
<!-- Right ear -->
|
||||||
android:strokeColor="#00000000" />
|
<path
|
||||||
</vector>
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M64,32 A10,10 0 1,1 84,32 A10,10 0 1,1 64,32 Z" />
|
||||||
|
|
||||||
|
<!-- Left inner ear -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFD0DD"
|
||||||
|
android:pathData="M28.5,32 A5.5,5.5 0 1,1 39.5,32 A5.5,5.5 0 1,1 28.5,32 Z" />
|
||||||
|
|
||||||
|
<!-- Right inner ear -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFD0DD"
|
||||||
|
android:pathData="M68.5,32 A5.5,5.5 0 1,1 79.5,32 A5.5,5.5 0 1,1 68.5,32 Z" />
|
||||||
|
|
||||||
|
<!-- Face (round, white) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M32,50 A22,22 0 1,1 76,50 A22,22 0 1,1 32,50 Z" />
|
||||||
|
|
||||||
|
<!-- Left eye -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF6B8A"
|
||||||
|
android:pathData="M41,43 A3,3 0 1,1 47,43 A3,3 0 1,1 41,43 Z" />
|
||||||
|
|
||||||
|
<!-- Right eye -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF6B8A"
|
||||||
|
android:pathData="M61,43 A3,3 0 1,1 67,43 A3,3 0 1,1 61,43 Z" />
|
||||||
|
|
||||||
|
<!-- Eye highlights (tiny white dots for cuteness) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M42.5,42 A1.2,1.2 0 1,1 44.9,42 A1.2,1.2 0 1,1 42.5,42 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M62.5,42 A1.2,1.2 0 1,1 64.9,42 A1.2,1.2 0 1,1 62.5,42 Z" />
|
||||||
|
|
||||||
|
<!-- Nose (small pink triangle) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8BBC"
|
||||||
|
android:pathData="M51,49 L57,49 L54,53.5 Z" />
|
||||||
|
|
||||||
|
<!-- Mouth (cute curved lines) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FF8BBC"
|
||||||
|
android:strokeWidth="1.2"
|
||||||
|
android:pathData="M49,55.5 Q51.5,59 54,55.5 Q56.5,59 59,55.5" />
|
||||||
|
|
||||||
|
<!-- Left cheek blush -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFD0DD"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M35,52 A4.5,3 0 1,1 44,52 A4.5,3 0 1,1 35,52 Z" />
|
||||||
|
|
||||||
|
<!-- Right cheek blush -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFD0DD"
|
||||||
|
android:fillAlpha="0.5"
|
||||||
|
android:pathData="M64,52 A4.5,3 0 1,1 73,52 A4.5,3 0 1,1 64,52 Z" />
|
||||||
|
|
||||||
|
<!-- Heart badge on chest (好孕 accent) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF8BBC"
|
||||||
|
android:pathData="M54,65 C54,65 48,60 48,57 C48,54.5 50,53 52.5,53 C53.8,53 54,54 54,54 C54,54 54.2,53 55.5,53 C58,53 60,54.5 60,57 C60,60 54,65 54,65 Z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 740 B |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 702 B |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 2.4 KiB |