大法师
@@ -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>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/library" />
|
||||
<option value="$PROJECT_DIR$/openCVLibrary300" />
|
||||
</set>
|
||||
</option>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<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.compose.material3)
|
||||
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.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
@@ -59,6 +60,7 @@ dependencies {
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(project(":library"))
|
||||
implementation(project(":openCVLibrary300"))
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
@@ -78,7 +78,9 @@ fun PinkOVApp() {
|
||||
AppDestinations.HOME -> HomePage(
|
||||
onScanClick = { showScanPage = true }
|
||||
)
|
||||
AppDestinations.RECORDS -> RecordsPage()
|
||||
AppDestinations.RECORDS -> RecordsPage(
|
||||
onScanClick = { showScanPage = true }
|
||||
)
|
||||
AppDestinations.MINE -> MinePage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import com.pinkbear.pinkov.data.entity.ScanRecord
|
||||
|
||||
@Database(
|
||||
entities = [ScanRecord::class, PeriodSettings::class, DailyRecord::class],
|
||||
version = 2,
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
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 {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
@@ -63,7 +135,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"pinkov_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
@@ -24,19 +24,91 @@ data class DailyRecord(
|
||||
@ColumnInfo(name = "is_period_start")
|
||||
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. */
|
||||
@ColumnInfo(name = "had_sex")
|
||||
val hadSex: Boolean = false,
|
||||
|
||||
/** Mood tag (e.g. 焦虑, 期待, 低落, 放松). Null = not recorded. */
|
||||
@ColumnInfo(name = "mood")
|
||||
val mood: String? = null,
|
||||
/** Sex method (e.g. 无措施, 避孕套, 体外排精…). Null = not recorded. */
|
||||
@ColumnInfo(name = "sex_method")
|
||||
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")
|
||||
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")
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
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 com.pinkbear.pinkov.R
|
||||
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.format.DateTimeFormatter
|
||||
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.
|
||||
*
|
||||
* When no date is selected, shows a hint. When a date is selected,
|
||||
* shows the "月经来了" switch and (in future) more check-in items.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckInPanel(
|
||||
selectedDate: LocalDate?,
|
||||
record: DailyRecord?,
|
||||
onPeriodStartChanged: (LocalDate, Boolean) -> Unit,
|
||||
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,
|
||||
) {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日", Locale.CHINESE)
|
||||
var openSheet by remember { mutableStateOf<CheckInSheet?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -69,25 +111,233 @@ fun CheckInPanel(
|
||||
onPeriodStartChanged(selectedDate, checked)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
|
||||
// ---- Placeholder items (future) ----
|
||||
PlaceholderCheckInRow(label = "爱爱")
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
// ---- 13 check-in entries ----
|
||||
// 1. 排卵试纸 → navigate to scan
|
||||
CheckInEntryRow(
|
||||
iconRes = R.drawable.record_icon_pailuan,
|
||||
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, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
|
||||
// 2. 基础体温
|
||||
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
|
||||
@@ -112,24 +362,71 @@ private fun CheckInSwitchRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single check-in entry row: icon | label | value/arrow.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaceholderCheckInRow(label: String) {
|
||||
private fun CheckInEntryRow(
|
||||
iconRes: Int,
|
||||
label: String,
|
||||
valueText: String?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (valueText != null) {
|
||||
Text(
|
||||
text = "—",
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = valueText,
|
||||
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,9 +118,12 @@ fun HomePage(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
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.08f),
|
||||
|
||||
Color.Black.copy(alpha = 0.03f),
|
||||
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -133,7 +136,7 @@ fun HomePage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
@@ -147,7 +150,7 @@ fun HomePage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -199,7 +202,7 @@ private fun RecordHeaderRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -264,7 +267,7 @@ private fun RecordRow(
|
||||
"阴性" -> Color(0xFF4CAF50)
|
||||
"阳性" -> Color(0xFFFF9800)
|
||||
"强阳" -> Color(0xFFF44336)
|
||||
else -> Color.White
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
Card(
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -82,65 +83,75 @@ fun MinePage(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = MaterialTheme.colorScheme.outlineVariant
|
||||
// )
|
||||
|
||||
// ---- Bottom ~80%: Menu items ----
|
||||
// ---- Menu items ----
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
// Group 1: 关于我们, 版本更新, 经期设置
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column {
|
||||
MenuItemRow(
|
||||
icon = Icons.Default.Info,
|
||||
title = "关于我们",
|
||||
onClick = { /* TODO: navigate to about page */ }
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
MenuItemRow(
|
||||
icon = Icons.Default.Refresh,
|
||||
title = "版本更新",
|
||||
onClick = { /* TODO: check for updates */ }
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
// ---- 经期设置 (above 设置) ----
|
||||
MenuItemRow(
|
||||
icon = Icons.Default.Favorite,
|
||||
title = "经期设置",
|
||||
onClick = { showPeriodSettings = true }
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// Group 2: 设置
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
MenuItemRow(
|
||||
icon = Icons.Default.Settings,
|
||||
title = "设置",
|
||||
onClick = { /* TODO: navigate to settings page */ }
|
||||
)
|
||||
}
|
||||
|
||||
// Push version text to bottom
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "v1.0.0",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
// Text(
|
||||
// text = "v1.0.0",
|
||||
// color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// fontSize = 12.sp,
|
||||
// modifier = Modifier
|
||||
// .align(Alignment.CenterHorizontally)
|
||||
// .padding(bottom = 16.dp)
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +189,7 @@ private fun MenuItemRow(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -45,6 +47,7 @@ import java.time.LocalDate
|
||||
@Composable
|
||||
fun RecordsPage(
|
||||
modifier: Modifier = Modifier,
|
||||
onScanClick: () -> Unit = {},
|
||||
recordsViewModel: RecordsViewModel = viewModel(),
|
||||
) {
|
||||
val calendarState = rememberSelectableCalendarState(
|
||||
@@ -77,8 +80,9 @@ fun RecordsPage(
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Calendar section
|
||||
SelectableCalendar(
|
||||
@@ -114,6 +118,43 @@ fun RecordsPage(
|
||||
onPeriodStartChanged = { 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. */
|
||||
private suspend fun clearPeriodStartsInMonth(date: LocalDate) {
|
||||
val monthStart = date.withDayOfMonth(1)
|
||||
|
||||
@@ -1,170 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
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" />
|
||||
<!-- Radial pink gradient matching theme primary: #FF8BBC → #FFC1D4 -->
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:centerX="54"
|
||||
android:centerY="54"
|
||||
android:gradientRadius="76"
|
||||
android:type="radial">
|
||||
<item
|
||||
android:color="#FFFFC1D4"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#FFFF8BBC"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
||||
@@ -1,30 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="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">
|
||||
<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>
|
||||
|
||||
<!-- Left ear -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
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"
|
||||
android:strokeColor="#00000000" />
|
||||
android:pathData="M24,32 A10,10 0 1,1 44,32 A10,10 0 1,1 24,32 Z" />
|
||||
|
||||
<!-- Right ear -->
|
||||
<path
|
||||
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 |