From 63fa6bd50dbd0301a135c58f30356ebec38aad45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 00:50:38 +0000 Subject: [PATCH 1/8] Phase 2: group data model (migration 23 to 24) with colour inheritance Adds the redesign's only genuinely new table: a category group that owns a colour role and a default input type. Fully additive; no UI changes. - Group entity (table `groups`) + GroupDao; TrackingCategory gains a nullable groupId (no FK: deleting a group unfiles members instead of cascading). MIGRATION_23_24 creates the table and adds the column; existing rows keep groupId NULL and their colorToken, so nothing changes visually after migrating. - Group CRUD, reorder, delete-with-unfile, and assign/unassign methods on TrackingRepository (new nullable groupDao constructor param, wired in GoFloApplication). - Colour inheritance per PLAN.md section 5: new "inherit" colorToken sentinel resolved by TrackingCategory.effectiveColorToken(groups) to the group's colorRole, or neutral surfaceVariant when groupless. Deliberately not a CategoryColor entry so the Phase 1 role picker does not offer it. Existing tokens are untouched (no grey wipe; confirmed with owner against the handover's neutral-by-default). - Migration test (Migration23To24Test): runs the real MIGRATION_23_24 against a real SQLite v23 schema via sqlite-jdbc (test-only dep), routing execSQL through a reflection proxy, and asserts the exact schema Room expects plus data preservation. MigrationTestHelper is unusable here (no instrumented tests, exportSchema = false). - PLAN.md section 7 progress log updated; subsystem map 02 updated for v24; changelog fragment (minor); LESSONS.md entry on JVM-side Room migration testing. Verified: migration SQL executed against a seeded v23 schema in SQLite (PRAGMA output matches the test's expected schema exactly); a11y_check.py and wcag_check.py both clean. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- LESSONS.md | 3 + app/build.gradle.kts | 6 + .../java/com/mapgie/goflo/GoFloApplication.kt | 7 +- .../goflo/data/database/GoFloDatabase.kt | 40 ++- .../goflo/data/database/dao/GroupDao.kt | 38 +++ .../data/database/dao/TrackingCategoryDao.kt | 10 + .../goflo/data/database/entities/Group.kt | 33 +++ .../database/entities/TrackingCategory.kt | 11 +- .../data/repository/TrackingRepository.kt | 85 ++++++ .../goflo/ui/util/CategoryAppearance.kt | 37 +++ .../data/database/Migration23To24Test.kt | 247 ++++++++++++++++++ changelog/unreleased/group-data-model.json | 6 + docs/design/logging-redesign/PLAN.md | 2 +- .../subsystem-maps/02-category-data-model.md | 25 +- 14 files changed, 538 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/mapgie/goflo/data/database/dao/GroupDao.kt create mode 100644 app/src/main/java/com/mapgie/goflo/data/database/entities/Group.kt create mode 100644 app/src/test/java/com/mapgie/goflo/data/database/Migration23To24Test.kt create mode 100644 changelog/unreleased/group-data-model.json diff --git a/LESSONS.md b/LESSONS.md index 380827e..54e7f51 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -94,6 +94,9 @@ When a form has section labels ("Flow", "Symptoms") and entered values ("Medium" ### Data / State +**Room migrations can be tested on the JVM: real SQLite via sqlite-jdbc + a reflection proxy for `SupportSQLiteDatabase`** +Room's `MigrationTestHelper` needs instrumented tests *and* exported schema JSON (`exportSchema = true`); a project with neither can still test migrations properly. Build the pre-migration schema by hand in an in-memory database (`org.xerial:sqlite-jdbc`, test-only dependency), seed representative data, then run the actual `Migration` object through a `java.lang.reflect.Proxy` implementing `SupportSQLiteDatabase` that routes `execSQL` to JDBC and throws for anything else — migrations that only `execSQL` need nothing more, and the proxy compiles regardless of the interface's exact member list (hand-implementing the ~35-member interface risks a CI-only compile break). Assert the post-migration schema with `PRAGMA table_info` against the exact shape Room generates for the entity — including `DEFAULT` clauses, which must match the entity's `@ColumnInfo(defaultValue=…)` annotations or Room throws `IllegalStateException` at first open on device. This exercises the real migration SQL on a real SQLite engine in a plain unit test. + **Gate prediction display on window end, not window start** A prediction window (e.g. a 5-day expected period) should remain visible as long as any part of the window is current — gate on `windowEnd >= today`, not `windowStart >= today`. Gating on the start collapses the display to zero the moment the window begins, which is precisely when it matters most. Apply the same principle to any "active range" feature: fertility windows, ovulation windows, reminders that span multiple days. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9129505..f156aa6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,6 +86,12 @@ dependencies { // test-only dependencies are never shipped, so they don't belong on // the licenses attribution screen the catalog check keeps in sync. testImplementation("org.json:json:20240303") + // Real SQLite engine for JVM unit tests — lets migration tests execute the + // actual Migration SQL against a real database (Room's MigrationTestHelper + // needs instrumented tests + exported schemas, neither of which this + // project has). Test-only, so deliberately not in libs.versions.toml + // (same rationale as org.json above). + testImplementation("org.xerial:sqlite-jdbc:3.45.1.0") } // ── Keep assets/CHANGELOG.md in sync with the root copy ────────────────────── diff --git a/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt b/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt index 7ae3c04..57399b7 100644 --- a/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt +++ b/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt @@ -29,7 +29,12 @@ class GoFloApplication : Application() { val database by lazy { GoFloDatabase.getInstance(this) } val repository by lazy { PeriodRepository(database.periodDao(), database.symptomDao(), database.periodDayDao()) } val trackingRepository by lazy { - TrackingRepository(database.trackingCategoryDao(), database.trackingLogDao(), database.symptomDao()) + TrackingRepository( + categoryDao = database.trackingCategoryDao(), + logDao = database.trackingLogDao(), + groupDao = database.groupDao(), + symptomDao = database.symptomDao(), + ) } val customAlarmRepository by lazy { CustomAlarmRepository(database.customAlarmDao()) } val colorProfileDao by lazy { database.colorProfileDao() } diff --git a/app/src/main/java/com/mapgie/goflo/data/database/GoFloDatabase.kt b/app/src/main/java/com/mapgie/goflo/data/database/GoFloDatabase.kt index 3ad0a0f..13b4e3c 100644 --- a/app/src/main/java/com/mapgie/goflo/data/database/GoFloDatabase.kt +++ b/app/src/main/java/com/mapgie/goflo/data/database/GoFloDatabase.kt @@ -8,6 +8,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.mapgie.goflo.data.database.dao.ColorProfileDao import com.mapgie.goflo.data.database.dao.CustomAlarmDao +import com.mapgie.goflo.data.database.dao.GroupDao import com.mapgie.goflo.data.database.dao.PeriodDao import com.mapgie.goflo.data.database.dao.PeriodDayDao import com.mapgie.goflo.data.database.dao.SymptomDao @@ -16,6 +17,7 @@ import com.mapgie.goflo.data.database.dao.TrackingLogDao import com.mapgie.goflo.data.database.entities.ColorProfile import com.mapgie.goflo.data.database.entities.CustomAlarm import com.mapgie.goflo.data.database.entities.CustomAlarmCategory +import com.mapgie.goflo.data.database.entities.Group import com.mapgie.goflo.data.database.entities.PeriodDayEntry import com.mapgie.goflo.data.database.entities.PeriodEntry import com.mapgie.goflo.data.database.entities.SymptomEntry @@ -36,8 +38,9 @@ import com.mapgie.goflo.data.database.entities.TrackingValue CustomAlarm::class, CustomAlarmCategory::class, ColorProfile::class, + Group::class, ], - version = 23, + version = 24, exportSchema = false ) abstract class GoFloDatabase : RoomDatabase() { @@ -48,6 +51,7 @@ abstract class GoFloDatabase : RoomDatabase() { abstract fun trackingLogDao(): TrackingLogDao abstract fun customAlarmDao(): CustomAlarmDao abstract fun colorProfileDao(): ColorProfileDao + abstract fun groupDao(): GroupDao companion object { @Volatile private var instance: GoFloDatabase? = null @@ -695,6 +699,38 @@ abstract class GoFloDatabase : RoomDatabase() { } } + /** + * Adds the `groups` table and a nullable groupId column on + * tracking_categories (v24). + * + * A group owns a colour role and a default input type; categories are + * optionally filed under one via groupId. No foreign key on groupId: + * deleting a group unfiles its members (repository sets groupId NULL) + * rather than cascading, and an FK would force a full table rebuild. + * + * Existing rows are untouched: groupId is NULL everywhere and every + * category keeps its own colorToken, so nothing changes visually. + * + * The DEFAULT clauses must stay in sync with the @ColumnInfo + * defaultValue annotations on [Group] — Room validates the migrated + * schema against the entity on first open. + */ + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """CREATE TABLE IF NOT EXISTS `groups` + (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `colorRole` TEXT NOT NULL DEFAULT 'primary', + `defaultInputType` TEXT NOT NULL DEFAULT 'default', + `displayOrder` INTEGER NOT NULL DEFAULT 0)""" + ) + database.execSQL( + "ALTER TABLE tracking_categories ADD COLUMN `groupId` INTEGER" + ) + } + } + fun getInstance(context: Context): GoFloDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -702,7 +738,7 @@ abstract class GoFloDatabase : RoomDatabase() { GoFloDatabase::class.java, "goflo_database" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24) .addCallback(object : Callback() { override fun onOpen(db: SupportSQLiteDatabase) { super.onOpen(db) diff --git a/app/src/main/java/com/mapgie/goflo/data/database/dao/GroupDao.kt b/app/src/main/java/com/mapgie/goflo/data/database/dao/GroupDao.kt new file mode 100644 index 0000000..51897fe --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/data/database/dao/GroupDao.kt @@ -0,0 +1,38 @@ +package com.mapgie.goflo.data.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.mapgie.goflo.data.database.entities.Group +import kotlinx.coroutines.flow.Flow + +/** + * Data access for category groups. + * + * `groups` is close to a SQL keyword, so the table name is always backticked + * in raw queries here and in migrations. + */ +@Dao +interface GroupDao { + + @Query("SELECT * FROM `groups` ORDER BY displayOrder ASC, name ASC") + fun getAllGroups(): Flow> + + @Query("SELECT * FROM `groups` ORDER BY displayOrder ASC, name ASC") + suspend fun getAllGroupsOnce(): List + + @Query("SELECT * FROM `groups` WHERE id = :id") + suspend fun getGroupById(id: Long): Group? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGroup(group: Group): Long + + @Update + suspend fun updateGroup(group: Group) + + @Delete + suspend fun deleteGroup(group: Group) +} diff --git a/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingCategoryDao.kt b/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingCategoryDao.kt index db56ca8..44c1015 100644 --- a/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingCategoryDao.kt +++ b/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingCategoryDao.kt @@ -59,6 +59,16 @@ interface TrackingCategoryDao { @Delete suspend fun deleteCategory(category: TrackingCategory) + // ── Groups ──────────────────────────────────────────────────────────── + + /** Files the category under [groupId], or unfiles it when null. */ + @Query("UPDATE tracking_categories SET groupId = :groupId WHERE id = :categoryId") + suspend fun assignCategoryToGroup(categoryId: Long, groupId: Long?) + + /** Unfiles every member of a group. Called before the group row is deleted. */ + @Query("UPDATE tracking_categories SET groupId = NULL WHERE groupId = :groupId") + suspend fun clearGroupAssignments(groupId: Long) + // ── Values ──────────────────────────────────────────────────────────── @Query("SELECT * FROM tracking_values WHERE categoryId = :categoryId ORDER BY displayOrder ASC, id ASC") diff --git a/app/src/main/java/com/mapgie/goflo/data/database/entities/Group.kt b/app/src/main/java/com/mapgie/goflo/data/database/entities/Group.kt new file mode 100644 index 0000000..b9072c3 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/data/database/entities/Group.kt @@ -0,0 +1,33 @@ +package com.mapgie.goflo.data.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * An optional grouping of tracking categories (e.g. "Sleep", "Pain"). + * + * A group owns a colour role and a default input type for the categories filed + * under it. Categories reference a group via [TrackingCategory.groupId]; a + * category whose [TrackingCategory.colorToken] is the "inherit" sentinel + * resolves its colour from the group's [colorRole] (see + * [com.mapgie.goflo.ui.util.effectiveColorToken]). + * + * [colorRole] is always a [com.mapgie.goflo.ui.util.CategoryColor] key + * ("primary", "secondary", "tertiary", "quaternary", "quinary", "senary") — + * never a raw hex value; groups are in-theme by design. + * + * [defaultInputType] is a [com.mapgie.goflo.ui.util.CategoryType] key, used to + * pre-select the input type when creating a category inside the group. + * + * Deleting a group never deletes its categories — members are unfiled + * (groupId set to null) first; see TrackingRepository.deleteGroup. + */ +@Entity(tableName = "groups") +data class Group( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + @ColumnInfo(defaultValue = "primary") val colorRole: String = "primary", + @ColumnInfo(defaultValue = "default") val defaultInputType: String = "default", + @ColumnInfo(defaultValue = "0") val displayOrder: Int = 0, +) diff --git a/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt b/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt index 0170523..04ba7be 100644 --- a/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt +++ b/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt @@ -18,10 +18,16 @@ import androidx.room.PrimaryKey * [iconName] maps to a [com.mapgie.goflo.ui.util.CategoryIcon] key string. * * [colorToken] maps to a [com.mapgie.goflo.ui.util.CategoryColor] key string - * ("primary", "secondary", "tertiary"). The token is resolved to an actual + * ("primary", "secondary", "tertiary", ...). The token is resolved to an actual * [androidx.compose.ui.graphics.Color] at render time via * [com.mapgie.goflo.ui.util.toCategoryColor], so the bubble automatically - * follows the user's chosen palette and light/dark mode. + * follows the user's chosen palette and light/dark mode. The sentinel value + * "inherit" defers to the owning group's [Group.colorRole] (neutral when the + * category has no group); resolve it via + * [com.mapgie.goflo.ui.util.effectiveColorToken] before rendering. + * + * [groupId] optionally files this category under a [Group]. Nullable, no + * foreign key: deleting a group unfiles its members rather than cascading. * * [categoryType] is one of "default" | "numeric_slider" | "numeric_free" | "increment" * (see [com.mapgie.goflo.ui.util.CategoryType]). It is immutable after creation. @@ -58,6 +64,7 @@ data class TrackingCategory( * Empty string for system categories and manually created categories. * Used to deduplicate mode suggestions across modes. */ @ColumnInfo(defaultValue = "") val modeKey: String = "", + val groupId: Long? = null, ) { val isNumeric: Boolean get() = categoryType != "default" } diff --git a/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt b/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt index 5873032..4e4a8fd 100644 --- a/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt +++ b/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt @@ -1,9 +1,11 @@ package com.mapgie.goflo.data.repository +import com.mapgie.goflo.data.database.dao.GroupDao import com.mapgie.goflo.data.database.dao.SymptomDao import com.mapgie.goflo.data.database.dao.TrackingCategoryDao import com.mapgie.goflo.data.database.dao.TrackingLogDao import com.mapgie.goflo.data.database.dao.ValueCount +import com.mapgie.goflo.data.database.entities.Group import com.mapgie.goflo.data.database.entities.TrackingCategory import com.mapgie.goflo.data.database.entities.TrackingLog import com.mapgie.goflo.data.database.entities.TrackingLogValue @@ -24,6 +26,7 @@ data class TrackingLogWithValues( class TrackingRepository( private val categoryDao: TrackingCategoryDao, private val logDao: TrackingLogDao, + private val groupDao: GroupDao? = null, private val symptomDao: SymptomDao? = null, ) { @@ -565,4 +568,86 @@ class TrackingRepository( } } } + + // ── Groups ──────────────────────────────────────────────────────────────── + + private val groups: GroupDao + get() = checkNotNull(groupDao) { "TrackingRepository was built without a GroupDao" } + + fun getAllGroups(): Flow> = + groups.getAllGroups() + + suspend fun getAllGroupsOnce(): List = + groups.getAllGroupsOnce() + + suspend fun getGroupByIdOnce(id: Long): Group? = + groups.getGroupById(id) + + suspend fun addGroup( + name: String, + colorRole: String = "primary", + defaultInputType: String = "default", + ): Long { + val maxOrder = groups.getAllGroupsOnce().maxOfOrNull { it.displayOrder } ?: -1 + return groups.insertGroup( + Group( + name = name.trim(), + colorRole = colorRole, + defaultInputType = defaultInputType, + displayOrder = maxOrder + 1, + ) + ) + } + + suspend fun renameGroup(id: Long, newName: String) { + val group = groups.getGroupById(id) ?: return + val trimmed = newName.trim() + if (trimmed.isNotEmpty() && trimmed != group.name) { + groups.updateGroup(group.copy(name = trimmed)) + } + } + + suspend fun updateGroupRole(id: Long, colorRole: String) { + val group = groups.getGroupById(id) ?: return + if (group.colorRole != colorRole) { + groups.updateGroup(group.copy(colorRole = colorRole)) + } + } + + suspend fun updateGroupDefaultInputType(id: Long, defaultInputType: String) { + val group = groups.getGroupById(id) ?: return + if (group.defaultInputType != defaultInputType) { + groups.updateGroup(group.copy(defaultInputType = defaultInputType)) + } + } + + suspend fun reorderGroups(orderedIds: List) { + orderedIds.forEachIndexed { newOrder, id -> + val group = groups.getGroupById(id) ?: return@forEachIndexed + if (group.displayOrder != newOrder) { + groups.updateGroup(group.copy(displayOrder = newOrder)) + } + } + } + + /** + * Deletes a group without touching its member categories or their history: + * members are unfiled (groupId set to null) first, so an inherit-coloured + * member falls back to the neutral rendering rather than dangling. + */ + suspend fun deleteGroup(id: Long) { + val group = groups.getGroupById(id) ?: return + categoryDao.clearGroupAssignments(id) + groups.deleteGroup(group) + } + + /** Files a category under [groupId]. */ + suspend fun assignCategoryToGroup(categoryId: Long, groupId: Long) { + categoryDao.assignCategoryToGroup(categoryId, groupId) + } + + /** Removes a category from its group, if any. */ + suspend fun unassignCategory(categoryId: Long) { + categoryDao.assignCategoryToGroup(categoryId, null) + } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt b/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt index 489398e..93f61c4 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector +import com.mapgie.goflo.data.database.entities.Group +import com.mapgie.goflo.data.database.entities.TrackingCategory import com.mapgie.goflo.ui.theme.LocalExtendedRoles // ── Icon catalogue ──────────────────────────────────────────────────────────── @@ -143,6 +145,38 @@ enum class CategoryColor( SENARY ("senary", "Senary"), } +/** + * Sentinel [TrackingCategory.colorToken] value meaning "follow my group's + * colour role". Deliberately NOT a [CategoryColor] entry so the role picker + * does not offer it as a standalone colour; the group-management UI (Phase 6) + * is the intended way to opt in. + * + * Resolution: [effectiveColorToken] maps it to the group's + * [Group.colorRole]; with no group it stays "inherit", which + * [toCategoryColor]/[toCategoryOnColor] render as the neutral + * surfaceVariant/onSurfaceVariant pair. + */ +const val COLOR_TOKEN_INHERIT = "inherit" + +/** + * Resolves the token this category should actually render with, applying the + * group colour-inheritance rule: + * + * - A category with its own token (every pre-existing category) uses it + * unchanged, whether or not it belongs to a group. + * - `"inherit"` with a group resolves to the group's [Group.colorRole]. + * - `"inherit"` without a group (or with a dangling groupId) stays + * `"inherit"`, which renders neutral. + * + * Call this before [toCategoryColor]/[toCategoryOnColor] wherever a category + * bubble is drawn and groups are available. + */ +fun TrackingCategory.effectiveColorToken(groups: List): String { + if (colorToken != COLOR_TOKEN_INHERIT) return colorToken + val group = groupId?.let { id -> groups.firstOrNull { it.id == id } } + return group?.colorRole ?: COLOR_TOKEN_INHERIT +} + /** * Extended colour palette offered in the "More colours" section of the picker. * Values are fully-opaque ARGB ints; convert to a storage key via [toHexColorKey]. @@ -187,6 +221,8 @@ fun String.toCategoryColor(): Color { "quaternary" -> LocalExtendedRoles.current.quaternary "quinary" -> LocalExtendedRoles.current.quinary "senary" -> LocalExtendedRoles.current.senary + // Unresolved inherit (no group): neutral, per the group-inheritance rule. + COLOR_TOKEN_INHERIT -> s.surfaceVariant else -> runCatching { Color(toLong(16)) }.getOrDefault(s.secondary) } } @@ -237,6 +273,7 @@ fun String.toCategoryOnColor(): Color { "quaternary" -> LocalExtendedRoles.current.onQuaternary "quinary" -> LocalExtendedRoles.current.onQuinary "senary" -> LocalExtendedRoles.current.onSenary + COLOR_TOKEN_INHERIT -> s.onSurfaceVariant else -> { val bg = runCatching { Color(toLong(16)) }.getOrDefault(s.secondary) // WCAG: contrast ≥ 3:1 for icons. luminance > 0.35 means the background diff --git a/app/src/test/java/com/mapgie/goflo/data/database/Migration23To24Test.kt b/app/src/test/java/com/mapgie/goflo/data/database/Migration23To24Test.kt new file mode 100644 index 0000000..567bc6d --- /dev/null +++ b/app/src/test/java/com/mapgie/goflo/data/database/Migration23To24Test.kt @@ -0,0 +1,247 @@ +package com.mapgie.goflo.data.database + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy +import java.sql.Connection +import java.sql.DriverManager + +/** + * Exercises the real [GoFloDatabase.MIGRATION_23_24] SQL against a real SQLite + * database (via sqlite-jdbc) seeded with the v23 schema and representative data. + * + * Room's MigrationTestHelper is not usable here: it requires instrumented tests + * and exported schema JSON, and this project has neither (exportSchema = false, + * JVM-only test source set). Instead the migration object's execSQL calls are + * routed to JDBC through a reflection proxy — MIGRATION_23_24 only ever calls + * execSQL(String), so no other SupportSQLiteDatabase member is needed. + * + * The expected `groups` schema asserted below must match what Room generates + * for the Group entity (including the DEFAULT clauses declared via + * @ColumnInfo(defaultValue = ...)); Room validates the migrated schema against + * the entity on first open, so a drift here is a crash on device. + */ +class Migration23To24Test { + + private lateinit var connection: Connection + + /** Routes SupportSQLiteDatabase.execSQL to JDBC; anything else fails the test. */ + private val supportDb: SupportSQLiteDatabase by lazy { + Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java) + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + connection.createStatement().use { it.execute(args!![0] as String) } + null + } + else -> throw UnsupportedOperationException( + "MIGRATION_23_24 called ${method.name}, which this test does not fake" + ) + } + } as SupportSQLiteDatabase + } + + @Before + fun createV23Database() { + connection = DriverManager.getConnection("jdbc:sqlite::memory:") + exec("PRAGMA foreign_keys = ON") + + // The v23 shape of the four tracking tables, as produced by the real + // migration chain (MIGRATION_6_7 rebuild + subsequent ADD COLUMNs). + exec( + """CREATE TABLE tracking_categories + (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `isSystem` INTEGER NOT NULL DEFAULT 0, + `displayOrder` INTEGER NOT NULL DEFAULT 0, + `iconName` TEXT NOT NULL DEFAULT 'category', + `colorToken` TEXT NOT NULL DEFAULT 'secondary', + `categoryType` TEXT NOT NULL DEFAULT 'default', + `numericMin` REAL NOT NULL DEFAULT 0.0, + `numericMax` REAL NOT NULL DEFAULT 10.0, + `allowDecimals` INTEGER NOT NULL DEFAULT 0, + `numericUnit` TEXT NOT NULL DEFAULT '', + `isArchived` INTEGER NOT NULL DEFAULT 0, + `allowMultiple` INTEGER NOT NULL DEFAULT 0, + `showInLogPeriod` INTEGER NOT NULL DEFAULT 0, + `scaleLabels` TEXT NOT NULL DEFAULT '', + `systemKey` TEXT NOT NULL DEFAULT '', + `trackAgainstTime` INTEGER NOT NULL DEFAULT 0, + `modeKey` TEXT NOT NULL DEFAULT '')""" + ) + exec( + """CREATE TABLE tracking_values + (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `categoryId` INTEGER NOT NULL, + `label` TEXT NOT NULL, + `displayOrder` INTEGER NOT NULL DEFAULT 0, + `isSeeded` INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(`categoryId`) REFERENCES `tracking_categories`(`id`) ON DELETE CASCADE)""" + ) + exec( + """CREATE TABLE tracking_logs + (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `date` TEXT NOT NULL, + `categoryId` INTEGER NOT NULL, + `notes` TEXT NOT NULL DEFAULT '', + `loggedAt` TEXT NOT NULL DEFAULT '', + FOREIGN KEY(`categoryId`) REFERENCES `tracking_categories`(`id`) ON DELETE CASCADE)""" + ) + exec( + """CREATE TABLE tracking_log_values + (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `logId` INTEGER NOT NULL, + `valueLabel` TEXT NOT NULL, + FOREIGN KEY(`logId`) REFERENCES `tracking_logs`(`id`) ON DELETE CASCADE)""" + ) + + // Representative v23 data: the two seeded system categories with their + // real colour tokens, one custom category, catalog values, and logs. + exec( + "INSERT INTO tracking_categories (id, name, isSystem, systemKey, displayOrder, iconName, colorToken, showInLogPeriod) " + + "VALUES (1, 'Flow', 1, 'flow', 0, 'water', 'primary', 1)" + ) + exec( + "INSERT INTO tracking_categories (id, name, isSystem, systemKey, displayOrder, iconName, colorToken, showInLogPeriod) " + + "VALUES (2, 'Symptoms', 1, 'symptoms', 1, 'healing', 'tertiary', 1)" + ) + exec( + "INSERT INTO tracking_categories (id, name, displayOrder, iconName, colorToken, categoryType, allowMultiple, trackAgainstTime) " + + "VALUES (3, 'Mood', 2, 'mood', 'quaternary', 'numeric_slider', 1, 1)" + ) + listOf("Spotting", "Light", "Medium", "Heavy").forEachIndexed { i, label -> + exec("INSERT INTO tracking_values (categoryId, label, displayOrder, isSeeded) VALUES (1, '$label', $i, 1)") + } + exec("INSERT INTO tracking_logs (id, date, categoryId, notes, loggedAt) VALUES (1, '2026-08-01', 1, 'a note', '')") + exec("INSERT INTO tracking_logs (id, date, categoryId, notes, loggedAt) VALUES (2, '2026-08-01', 3, '', '09:30')") + exec("INSERT INTO tracking_log_values (logId, valueLabel) VALUES (1, 'Medium')") + exec("INSERT INTO tracking_log_values (logId, valueLabel) VALUES (2, '4')") + } + + @After + fun tearDown() { + connection.close() + } + + @Test + fun `creates groups table with the exact schema Room expects`() { + GoFloDatabase.MIGRATION_23_24.migrate(supportDb) + + // (name, type, notnull, dflt_value, pk) per column, in declaration order. + val columns = tableInfo("groups") + assertEquals( + listOf( + listOf("id", "INTEGER", 1, null, 1), + listOf("name", "TEXT", 1, null, 0), + listOf("colorRole", "TEXT", 1, "'primary'", 0), + listOf("defaultInputType", "TEXT", 1, "'default'", 0), + listOf("displayOrder", "INTEGER", 1, "0", 0), + ), + columns + ) + + // autoGenerate = true → Room expects AUTOINCREMENT on the PK. + val createSql = queryString("SELECT sql FROM sqlite_master WHERE type='table' AND name='groups'") + assertTrue("groups PK must be AUTOINCREMENT", createSql!!.contains("AUTOINCREMENT")) + } + + @Test + fun `adds nullable groupId column defaulting to null on every existing row`() { + GoFloDatabase.MIGRATION_23_24.migrate(supportDb) + + val groupIdColumn = tableInfo("tracking_categories").firstOrNull { it[0] == "groupId" } + assertEquals(listOf("groupId", "INTEGER", 0, null, 0), groupIdColumn) + + assertEquals(3, queryInt("SELECT COUNT(*) FROM tracking_categories")) + assertEquals(0, queryInt("SELECT COUNT(*) FROM tracking_categories WHERE groupId IS NOT NULL")) + } + + @Test + fun `preserves all pre-existing category, value, and log data`() { + GoFloDatabase.MIGRATION_23_24.migrate(supportDb) + + assertEquals(4, queryInt("SELECT COUNT(*) FROM tracking_values")) + assertEquals(2, queryInt("SELECT COUNT(*) FROM tracking_logs")) + assertEquals(2, queryInt("SELECT COUNT(*) FROM tracking_log_values")) + + // Colour tokens survive untouched — the no-grey-wipe guarantee. + assertEquals("primary", queryString("SELECT colorToken FROM tracking_categories WHERE id = 1")) + assertEquals("tertiary", queryString("SELECT colorToken FROM tracking_categories WHERE id = 2")) + assertEquals("quaternary", queryString("SELECT colorToken FROM tracking_categories WHERE id = 3")) + + // Spot-check a full custom-category row and a log's linkage. + connection.createStatement().use { st -> + val rs = st.executeQuery( + "SELECT name, categoryType, allowMultiple, trackAgainstTime, groupId FROM tracking_categories WHERE id = 3" + ) + assertTrue(rs.next()) + assertEquals("Mood", rs.getString("name")) + assertEquals("numeric_slider", rs.getString("categoryType")) + assertEquals(1, rs.getInt("allowMultiple")) + assertEquals(1, rs.getInt("trackAgainstTime")) + rs.getLong("groupId") + assertTrue(rs.wasNull()) + } + assertEquals("Medium", queryString("SELECT valueLabel FROM tracking_log_values WHERE logId = 1")) + assertEquals("09:30", queryString("SELECT loggedAt FROM tracking_logs WHERE id = 2")) + } + + @Test + fun `groups table accepts inserts and supports the unfile-on-delete flow`() { + GoFloDatabase.MIGRATION_23_24.migrate(supportDb) + + exec("INSERT INTO `groups` (name, colorRole, defaultInputType, displayOrder) VALUES ('Sleep', 'quinary', 'numeric_slider', 0)") + val groupId = queryInt("SELECT id FROM `groups` WHERE name = 'Sleep'") + exec("UPDATE tracking_categories SET groupId = $groupId WHERE id = 3") + assertEquals(groupId, queryInt("SELECT groupId FROM tracking_categories WHERE id = 3")) + + // Repository delete order: unfile members, then delete the group row. + exec("UPDATE tracking_categories SET groupId = NULL WHERE groupId = $groupId") + exec("DELETE FROM `groups` WHERE id = $groupId") + assertNull(queryString("SELECT groupId FROM tracking_categories WHERE id = 3")) + assertEquals(3, queryInt("SELECT COUNT(*) FROM tracking_categories")) + } + + // ── JDBC helpers ────────────────────────────────────────────────────────── + + private fun exec(sql: String) { + connection.createStatement().use { it.execute(sql) } + } + + private fun queryInt(sql: String): Int = + connection.createStatement().use { st -> + st.executeQuery(sql).use { rs -> rs.next(); rs.getInt(1) } + } + + private fun queryString(sql: String): String? = + connection.createStatement().use { st -> + st.executeQuery(sql).use { rs -> if (rs.next()) rs.getString(1) else null } + } + + /** Returns PRAGMA table_info rows as (name, type, notnull, dflt_value, pk). */ + private fun tableInfo(table: String): List> = + connection.createStatement().use { st -> + st.executeQuery("PRAGMA table_info(`$table`)").use { rs -> + buildList { + while (rs.next()) { + add( + listOf( + rs.getString("name"), + rs.getString("type"), + rs.getInt("notnull"), + rs.getString("dflt_value"), + rs.getInt("pk"), + ) + ) + } + } + } + } +} diff --git a/changelog/unreleased/group-data-model.json b/changelog/unreleased/group-data-model.json new file mode 100644 index 0000000..5c1f783 --- /dev/null +++ b/changelog/unreleased/group-data-model.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "added": [ + "Groups: categories can now belong to a group that owns a shared colour role and a default input type (data layer; management UI arrives in a later update)" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index 8207090..e6596f4 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -204,7 +204,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r |---|---|---|---|---| | 0 — Planning & groundwork | Done | this branch (`claude/design-handover-bdq9he`) | 23 | Subsystem maps stamped at `d07d947` / vc116. Theme-reconciliation split out as separate task. | | 1 — Extended colour roles | Done | `claude/logging-redesign-phase-1-f08e0o` | 23 | Derivation deviates from the sketch in two ways: near-greyscale accents (High Contrast) shift lightness instead of hue (hue rotation of grey is a no-op), and on-colours pick near-black vs white by max contrast rather than a 0.35 luminance threshold (the threshold has a 0.30-0.35 band where white fails 3:1). Spot-check script `wcag_check_roles.py` added (all 12 families + HC + Blue & Orange, worst ratio 4.24:1). Also restored `wcag_check.py`, which was committed as one base64 line in `84624bc`. | -| 2 — Group data model | Not started | | (24) | Colour-inheritance deviates from handover's "grey by default" to preserve existing colours. | +| 2 — Group data model | Done | `claude/logging-redesign-phase-2-nuaywv` | 24 | Colour-inheritance deviation (keep existing colours, opt-in `"inherit"`) confirmed with owner. `"inherit"` is a sentinel constant, not a `CategoryColor` entry, so the Phase 1 picker does not offer it. Group methods live in `TrackingRepository` (new nullable `groupDao` ctor param) rather than a separate repository. No FK on `groupId`; `deleteGroup` unfiles members first. Migration test is a JVM test (`Migration23To24Test`) driving the real migration through sqlite-jdbc, since the project has no instrumented tests and `exportSchema = false`. | | 3 — Component library | Not started | | | | | 4 — MetricInput + Yes/No + Time | Not started | | | | | 5 — Unified LogScreen | Not started | | | Consider sub-PRs. | diff --git a/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md b/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md index 8ad0dd6..d04a8d4 100644 --- a/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md +++ b/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md @@ -4,8 +4,9 @@ > - Commit: `d07d947` (`d07d947f5b2463eaa08e6521d3228026c55b2bef`) > - versionCode **116**, versionName **0.53.0-beta.1**, DB schema version **23** > - Date: 2026-08-22 +> - **Updated 2026-08-25 for Phase 2** (branch `claude/logging-redesign-phase-2-nuaywv`): DB is now **v24** — `groups` table, `TrackingCategory.groupId`, `GroupDao`, group methods on `TrackingRepository`, `"inherit"` colour sentinel. Sections below annotated in place. > -> **Staleness check for future sessions:** the DB class is `data/database/GoFloDatabase.kt`. Confirm its `version = N` before writing a migration — if it is no longer **23**, someone added migrations after this map; read them and target `N → N+1`. Run `git diff d07d947 -- app/src/main/java/com/mapgie/goflo/data/` to see drift. +> **Staleness check for future sessions:** the DB class is `data/database/GoFloDatabase.kt`. Confirm its `version = N` before writing a migration — if it is no longer **24**, someone added migrations after this map; read them and target `N → N+1`. Run `git diff d07d947 -- app/src/main/java/com/mapgie/goflo/data/` to see drift. All paths under `app/src/main/java/com/mapgie/goflo/`. **The DB class is `GoFloDatabase.kt`, not `AppDatabase.kt`.** @@ -34,9 +35,15 @@ The central category entity. **Note how much already exists** — icons, colour | `showInLogPeriod` | `Boolean` | `false` | pin to Log Period screen | | `trackAgainstTime` | `Boolean` | `false` | enables `loggedAt` time on logs | | `modeKey` | `String` | `""` | links to a tracking-mode preset | +| `groupId` | `Long?` | `null` | *(v24)* optional file-under-group link; no FK, deleting a group unfiles members | Computed (not a column): `val isNumeric get() = categoryType != "default"`. +### Group — table `groups` (`entities/Group.kt`) *(v24)* +Optional grouping of categories; owns a colour role and a default input type. +- `id: Long` PK autoGen, `name: String`, `colorRole: String = "primary"` (always a `CategoryColor` key, never hex), `defaultInputType: String = "default"` (a `CategoryType` key), `displayOrder: Int = 0`. Defaults carry `@ColumnInfo(defaultValue=…)` so Room DDL matches the migration SQL. +- Colour inheritance: a category with `colorToken == "inherit"` resolves to its group's `colorRole` via `effectiveColorToken(groups)` in `CategoryAppearance.kt`; inherit-without-group renders neutral (`surfaceVariant`). Existing categories keep their own tokens. + ### TrackingValue — table `tracking_values` Catalog of selectable options for a category (this is how "Spot/Light/Med/Heavy" are stored). - `id: Long` PK, `categoryId: Long` (FK → categories, `ON DELETE CASCADE`, indexed), `label: String`, `displayOrder: Int = 0`, `isSeeded: Boolean = false` (protects shipped values from deletion). @@ -64,13 +71,13 @@ Legacy per-period symptom, FK → `PeriodEntry` cascade. `id, periodId, symptomT ## 2. Database config — `data/database/GoFloDatabase.kt` -- **Current version: 23**, `exportSchema = false`. -- **@Database entities:** `PeriodEntry, PeriodDayEntry, SymptomEntry, TrackingCategory, TrackingValue, TrackingLog, TrackingLogValue, CustomAlarm, CustomAlarmCategory, ColorProfile`. +- **Current version: 24** *(was 23 at the original stamp)*, `exportSchema = false`. +- **@Database entities:** `PeriodEntry, PeriodDayEntry, SymptomEntry, TrackingCategory, TrackingValue, TrackingLog, TrackingLogValue, CustomAlarm, CustomAlarmCategory, ColorProfile, Group` *(v24)*. - Fresh installs seed via `onCreate → seedSystemCategories`: Flow (icon `water`, token `primary`, values Spotting/Light/Medium/Heavy) and Symptoms (icon `healing`, token `tertiary`, values Cramps/Headache/Bloating/Fatigue/Back Pain/Mood Swings/Bleeding (non-period)). Values seeded with `isSeeded=1`. - `PRAGMA foreign_keys = ON` re-applied on every open. ### Migration chain (all registered in `.addMigrations(...)`, chain 1→23) -1_2 create `custom_symptoms` (later dropped) · 2_3 create the 4 tracking tables + seed Flow/Symptoms · 3_4 add `iconName`+`colorArgb` · 4_5 rebuild → replace `colorArgb` with `colorToken` · 5_6 add `isNumeric,numericMin,numericMax,allowDecimals` · 6_7 rebuild → replace `isNumeric` with `categoryType`, add `numericUnit,isArchived` · 7_8 add `allowMultiple` · 8_9 add `showInLogPeriod` · 9_10 add `scaleLabels` · 10_11 add `systemKey` · 11_12 add `trackAgainstTime`+`loggedAt` · 12_13 insert "Bleeding (non-period)" · 13_14 add `isSeeded` · 14_15 convert enum names→labels, un-seed flow/symptom values, migrate custom_symptoms→values, drop custom_symptoms · 15_16 seed "Ovulation Test" · 16_17 add `modeKey` · 17_18 create `custom_alarms`+`custom_alarm_categories` · 18_19 demote Ovulation Test to non-system, `modeKey='ovulation_test'` · 19_20 `showInLogPeriod=1` for flow/symptoms · 20_21 create `color_profiles` · 21_22 add light/dark background argb · 22_23 create `period_days` (unique date), backfill from episode ranges. +1_2 create `custom_symptoms` (later dropped) · 2_3 create the 4 tracking tables + seed Flow/Symptoms · 3_4 add `iconName`+`colorArgb` · 4_5 rebuild → replace `colorArgb` with `colorToken` · 5_6 add `isNumeric,numericMin,numericMax,allowDecimals` · 6_7 rebuild → replace `isNumeric` with `categoryType`, add `numericUnit,isArchived` · 7_8 add `allowMultiple` · 8_9 add `showInLogPeriod` · 9_10 add `scaleLabels` · 10_11 add `systemKey` · 11_12 add `trackAgainstTime`+`loggedAt` · 12_13 insert "Bleeding (non-period)" · 13_14 add `isSeeded` · 14_15 convert enum names→labels, un-seed flow/symptom values, migrate custom_symptoms→values, drop custom_symptoms · 15_16 seed "Ovulation Test" · 16_17 add `modeKey` · 17_18 create `custom_alarms`+`custom_alarm_categories` · 18_19 demote Ovulation Test to non-system, `modeKey='ovulation_test'` · 19_20 `showInLogPeriod=1` for flow/symptoms · 20_21 create `color_profiles` · 21_22 add light/dark background argb · 22_23 create `period_days` (unique date), backfill from episode ranges · 23_24 create `groups` + add nullable `tracking_categories.groupId` (JVM migration test: `app/src/test/.../Migration23To24Test.kt`). **Pattern to follow:** additive column adds use `ALTER TABLE ... ADD COLUMN`; type changes do a full table rebuild (create-new, copy, drop, rename). Never `fallbackToDestructiveMigration` (forbidden by CLAUDE.md). @@ -79,15 +86,21 @@ Legacy per-period symptom, FK → `PeriodEntry` cascade. `id, periodId, symptomT ### TrackingCategoryDao Categories: `getAllCategories()`/`getActiveCategories()` (isArchived=0) Flows; `getCategoryById`/`…Once`; `getAllCategoriesOnce`; lookups `getSystemCategoryByName/ByKey`, `getCategoryByName`, `getCategoryByModeKey`; `getShowInLogPeriodCategoriesOnce`. Mutations: `insertCategory` (REPLACE→Long), `updateCategory`, `deleteCategory`, `deleteAllCustomCategories`, `unarchiveAllSystemCategories`. Values: `getValuesForCategory`/`…Once`, `insertValue` (IGNORE), `updateValue`, `deleteValue`, `bulkRenameLogValues(categoryId, oldLabel, newLabel)`. +Groups *(v24)*: `assignCategoryToGroup(categoryId, groupId?)`, `clearGroupAssignments(groupId)`. + +### GroupDao *(v24)* +`getAllGroups(): Flow` / `getAllGroupsOnce()` (ordered `displayOrder, name`), `getGroupById`, `insertGroup` (REPLACE→Long), `updateGroup`, `deleteGroup`. Table name is always backticked in raw SQL (`groups` is keyword-adjacent). ### TrackingLogDao Logs: `getLogsForDate`/`…Once`; `getAllLogDates`; `getLogById`/`…Once`; `getLogForDateAndCategory` (LIMIT 1); `getLogsForDateAndCategory` (multiple, ordered by loggedAt); `insertLog` (REPLACE→Long), `updateLog`, `deleteLog`. Log values: `getLogValuesForLog`/`…Once`, `insertLogValue`, `deleteLogValuesForLog`. Stats/export: `getLogsForCategoryInRange`, `getValueCountsForCategory` (→ `ValueCount`), `getAllLogsInRange`, `getLogsForCategoriesInRange`, `getAllLogsForCategories`, `getLogValuesForLogs`, `getEarliest/LatestLogDate`, delete ranges/date/all. ## 4. Repository — `data/repository/TrackingRepository.kt` -Constructor: `TrackingRepository(categoryDao, logDao, symptomDao?)`. Wrapper `TrackingLogWithValues(log, category, values: List)`. +Constructor: `TrackingRepository(categoryDao, logDao, groupDao?, symptomDao?)` *(groupDao added v24)*. Wrapper `TrackingLogWithValues(log, category, values: List)`. Category CRUD: `getAllCategories()/getActiveCategories(): Flow>`, `getAllCategoriesOnce()`, `getShowInLogPeriodCategoriesOnce()`, `getCategoryById(id)/…Once`, `getValuesForCategory(id)/…Once`, `addCategory(name, iconName, colorToken, categoryType, numericMin, numericMax, allowDecimals, numericUnit, scaleLabels, allowMultiple, showInLogPeriod, trackAgainstTime, modeKey): Long`, `renameCategory`, `updateCategoryAppearance(id, iconName, colorToken)`, `updateCategoryFullSettings(...)`, `updateTrackAgainstTime`, `updateNumericSettings`, `updateNumericUnit`, `updateShowInLogPeriod`, `updateAllowMultiple`, `updateFlowCategoryMode(id, useSlider)`, `archiveCategory`, `unarchiveCategory`, `deleteCategory` (guards `isSystem`), `reorderCategories`, `getExistingModeKeys`. Values: `addValueToCategory`, `deleteValue`, `renameValue(value, newLabel, fixHistorical)`. +Groups *(v24)*: `getAllGroups(): Flow` / `getAllGroupsOnce()`, `getGroupByIdOnce`, `addGroup(name, colorRole, defaultInputType)`, `renameGroup`, `updateGroupRole`, `updateGroupDefaultInputType`, `reorderGroups(orderedIds)`, `deleteGroup(id)` (unfiles members first — never deletes categories), `assignCategoryToGroup(categoryId, groupId)`, `unassignCategory(categoryId)`. + Log CRUD: `getLogsForDate(date): Flow>`, `getAllLogDates(): Flow>`, `saveLog(date, categoryId, selectedValues, notes, allowMultiple=false, loggedAt=""): Long` (upsert unless allowMultiple; deletes+re-inserts log values each time), `updateLogInPlace(...)`, `deleteLog`, `incrementLog(date, categoryId, delta=1): Int`, `getExistingLog`, `getLogsForDateAndCategory`, `getLogById`, stats/export accessors, `deleteAllLogs`, `resetCategoryConfiguration`, `deleteLogsForPeriod`, `syncFlowLogsForPeriod`. ## 5. Answers to the model questions that matter for the redesign @@ -98,4 +111,4 @@ Log CRUD: `getLogsForDate(date): Flow>`, `getAllLogD - **Icons** already exist (`iconName` → `CategoryIcon`, 20 icons). - **Allow-multiple-per-day** already exists (`allowMultiple`, honoured in `saveLog`). - **Timestamps / timeline** partially exist (`trackAgainstTime` + `loggedAt` + `TimedIncrementSection`). → The handover's generalised timeline extends this to all input types. -- **Groups / roles**: **NONE exist.** No group entity, no role field, no parent-child. Closest existing mechanisms: `modeKey`, `systemKey`, `showInLogPeriod`, `custom_alarm_categories` join table. A group/role model is entirely additive. +- **Groups / roles**: *(updated v24)* the `Group` entity, `TrackingCategory.groupId`, and the `"inherit"` colour sentinel now exist (Phase 2). No management UI yet — repository-level only until Phase 6. At the original stamp none of this existed; closest prior mechanisms were `modeKey`, `systemKey`, `showInLogPeriod`, `custom_alarm_categories`. From 62656807102d3e9a12d693939ec2fb5ce764acf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:40:08 +0000 Subject: [PATCH 2/8] Phase 3: reusable component library for the logging redesign Adds the ~12 stateless, parameterised Compose primitives every later redesign screen is assembled from, in ui/components/, previews only. No existing screen is rewired; nothing is removed. - SectionHeader: uppercase 11sp section label with right-aligned value - ListCard / ListRow / HairlineDivider: hairline-outlined card of rows - StepScale: discrete rising tap-steps; exposes to TalkBack as a single control ("Flow, Medium, 3 of 4") with per-step custom actions - ChipToggle / ChipRow: tonal-fill + check selection chips (FilterChip) - ToneHero: tonal hero container, Comfortaa word applied explicitly - SegmentedToggle: single-choice segmented buttons, optional role tint - RolePicker: 6 in-theme role pills + fixed-colour track, standalone (visuals mirrored from the Phase 1 inline picker; ManageCategories keeps its copy until a later phase rewires it) - IconPicker: 48dp icon tile grid with radio semantics + display names - SwitchRow: full-row toggle, Role.Switch + stateDescription, inner Switch has no click handler - Timeline / TimelineEntry: timestamped multiple-per-day list with per-row overflow and an append row - PrimarySaveBar: sticky 52dp pill with gradient fade - MetricInput facade stub + MetricConfig/MetricValue value types (incl. YesNo/TimeOfDay variants ready for Phase 4) Every primitive accents from a passed-in role Color (tonal container fills derived via roleContainerTint, lerp toward surface); each carries light, dark, and 200% font-scale previews through GoFloTheme so the extended-roles CompositionLocal path is exercised. a11y_check.py and wcag_check.py both clean. PLAN.md progress log updated; DB stays at 24. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- LESSONS.md | 3 + .../mapgie/goflo/ui/components/ChipToggle.kt | 129 +++++++++ .../goflo/ui/components/ComponentPreviews.kt | 37 +++ .../mapgie/goflo/ui/components/IconPicker.kt | 109 ++++++++ .../mapgie/goflo/ui/components/ListCard.kt | 162 ++++++++++++ .../mapgie/goflo/ui/components/MetricInput.kt | 249 ++++++++++++++++++ .../goflo/ui/components/PrimarySaveBar.kt | 115 ++++++++ .../mapgie/goflo/ui/components/RolePicker.kt | 216 +++++++++++++++ .../mapgie/goflo/ui/components/RoleTint.kt | 20 ++ .../goflo/ui/components/SectionHeader.kt | 88 +++++++ .../goflo/ui/components/SegmentedToggle.kt | 87 ++++++ .../mapgie/goflo/ui/components/StepScale.kt | 200 ++++++++++++++ .../mapgie/goflo/ui/components/SwitchRow.kt | 126 +++++++++ .../mapgie/goflo/ui/components/Timeline.kt | 231 ++++++++++++++++ .../mapgie/goflo/ui/components/ToneHero.kt | 113 ++++++++ .../unreleased/logging-redesign-phase-3.json | 4 + docs/design/logging-redesign/PLAN.md | 2 +- 17 files changed, 1890 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt create mode 100644 changelog/unreleased/logging-redesign-phase-3.json diff --git a/LESSONS.md b/LESSONS.md index 54e7f51..e1b2aa5 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -8,6 +8,9 @@ Entries within each section are ordered by risk to a new project if forgotten: b ### Android / Compose +**A `role: Color` parameter shadows the `role` semantics property — qualify with `this.role` inside `semantics {}`** +Components that take the category's colour as a `role: Color` parameter break the idiomatic `semantics { role = Role.Button }` assignment: inside the lambda, the enclosing function's `role` parameter shadows the `SemanticsPropertyReceiver.role` extension property, so the unqualified assignment tries to reassign the `val` parameter and fails to compile. Write `this.role = Role.Button` (and likewise `this.selected` / `this.contentDescription` when locals share those names) inside `semantics {}` and `clearAndSetSemantics {}` blocks. The qualified form still satisfies `a11y_check.py`'s pattern match. + **`SwipeToDismissBox`: use `confirmValueChange` returning `false` to intercept — not `LaunchedEffect` + `reset()`** When a swipe should show a confirmation dialog before committing, the natural-looking approach is `confirmValueChange = { true }` (allow the state change) then call `state.reset()` in the dialog's Cancel handler. This causes two bugs: (1) if the composition survives navigation, the `LaunchedEffect` key hasn't changed on return so the dialog silently re-appears; (2) `reset()` takes one animation frame, leaving a brief window where swiping is disabled. The correct pattern is `confirmValueChange = { newValue -> if (newValue == EndToStart) { showConfirm = true; false } else true }`. Returning `false` rejects the transition entirely — the box springs back immediately, no `reset()` call is needed, and the dialog fully controls the outcome. Remove the `LaunchedEffect` and the coroutine scope from the composable. diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt new file mode 100644 index 0000000..618f38e --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt @@ -0,0 +1,129 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A state chip in the redesign's selection language: selected fills with a + * tonal container of [role] and gains a leading check; unselected stays a + * hairline outline. The check means selection is never colour-only. + * + * Built on Material's [FilterChip], which carries checkbox-style toggle + * semantics and the minimum interactive size for the tap target. + */ +@Composable +fun ChipToggle( + text: String, + selected: Boolean, + role: Color, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = roleContainerTint(role, MaterialTheme.colorScheme.surface) + FilterChip( + selected = selected, + onClick = onToggle, + label = { Text(text, fontSize = 13.5.sp) }, + modifier = modifier, + leadingIcon = if (selected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + } + } else { + null + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.onSurface, + selectedContainerColor = container, + selectedLabelColor = MaterialTheme.colorScheme.onSurface, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface, + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + selectedBorderColor = Color.Transparent, + ), + ) +} + +/** + * A wrapping row of [ChipToggle]s for multi-select values (symptoms, text + * catalog values). State is hoisted: [selected] holds the chosen labels and + * [onToggle] fires with the tapped label. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ChipRow( + options: List, + selected: Set, + role: Color, + onToggle: (String) -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + ChipToggle( + text = option, + selected = option in selected, + role = role, + onToggle = { onToggle(option) }, + ) + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun ChipRowPreviewContent() { + SectionHeader(label = "Symptoms", value = "2 today") + ChipRow( + options = listOf("Cramps", "Nausea", "Headache", "Bloating", "Fatigue", "Back pain", "Mood swings"), + selected = setOf("Cramps", "Nausea"), + role = MaterialTheme.colorScheme.primary, + onToggle = {}, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun ChipRowPreviewLight() { + ComponentPreviewSurface { ChipRowPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun ChipRowPreviewDark() { + ComponentPreviewSurface(dark = true) { ChipRowPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun ChipRowPreviewLarge() { + ComponentPreviewSurface { ChipRowPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt new file mode 100644 index 0000000..b01b308 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt @@ -0,0 +1,37 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.ui.theme.AppTheme +import com.mapgie.goflo.ui.theme.GoFloTheme + +/** + * Shared scaffolding for the component-library `@Preview`s: wraps the previewed + * primitive in [GoFloTheme] (Coral light or Coral dark) on the themed + * background, so every preview exercises the real colour-resolution path + * (including [com.mapgie.goflo.ui.theme.LocalExtendedRoles]). + * + * Preview-only support code; not for use by screens. + */ +@Composable +internal fun ComponentPreviewSurface( + dark: Boolean = false, + content: @Composable () -> Unit, +) { + GoFloTheme(appTheme = if (dark) AppTheme.CORAL_DARK else AppTheme.CORAL) { + Surface(color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + content() + } + } + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt b/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt new file mode 100644 index 0000000..86b32a7 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt @@ -0,0 +1,109 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.ui.util.CategoryIcon + +/** + * The icon grid for category create/edit: 48dp rounded tiles, one per + * [CategoryIcon]. The selected tile fills with the category's [role] colour + * and every tile announces its display name with radio-button semantics, so + * selection is neither colour-only nor unlabelled. + * + * State is hoisted: [selectedKey] is the stored [CategoryIcon.key] and + * [onPick] fires with the tapped icon. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun IconPicker( + selectedKey: String?, + role: Color, + onRole: Color, + onPick: (CategoryIcon) -> Unit, + modifier: Modifier = Modifier, + icons: List = CategoryIcon.entries, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + icons.forEach { icon -> + val isSelected = icon.key == selectedKey + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .then( + if (isSelected) Modifier.background(role) + else Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)) + ) + .semantics { + this.role = Role.RadioButton + this.selected = isSelected + this.contentDescription = icon.displayName + } + .clickable { onPick(icon) }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon.vector, + contentDescription = null, + tint = if (isSelected) onRole else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun IconPickerPreviewContent() { + SectionHeader(label = "Icon") + IconPicker( + selectedKey = CategoryIcon.HEART.key, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onPick = {}, + icons = CategoryIcon.entries.take(10), + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun IconPickerPreviewLight() { + ComponentPreviewSurface { IconPickerPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun IconPickerPreviewDark() { + ComponentPreviewSurface(dark = true) { IconPickerPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun IconPickerPreviewLarge() { + ComponentPreviewSurface { IconPickerPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt new file mode 100644 index 0000000..78931cc --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt @@ -0,0 +1,162 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The white rounded card with hairline-divided rows: "one card per idea, not + * per field". Powers Dates, grouped tracked metrics, alarms, step labels, and + * add-to-group lists. + * + * Flat by design: a 1px hairline outline instead of a drop shadow. Compose + * [ListRow]s (separated by [HairlineDivider]) inside the content lambda. + */ +@Composable +fun ListCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + ) { + Column(content = content) + } +} + +/** The 1px low-contrast divider between [ListRow]s (about 7% onSurface). */ +@Composable +fun HairlineDivider(modifier: Modifier = Modifier) { + HorizontalDivider( + modifier = modifier, + thickness = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f), + ) +} + +/** + * One key/value row inside a [ListCard]: label on the left, tabular value on + * the right, optional trailing slot (defaults to a chevron when clickable). + * + * Rows are at least 52dp tall. A clickable row announces as a button. + */ +@Composable +fun ListRow( + key: String, + modifier: Modifier = Modifier, + value: String? = null, + valueEmphasis: Boolean = false, + valueColor: Color = MaterialTheme.colorScheme.onSurface, + trailing: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + val clickModifier = if (onClick != null) { + Modifier + .semantics { role = Role.Button } + .clickable(onClick = onClick) + } else { + Modifier + } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .then(clickModifier) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = key, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Text( + text = value, + fontSize = 14.sp, + fontWeight = if (valueEmphasis) FontWeight.SemiBold else FontWeight.Normal, + color = valueColor, + // Tabular figures so date/number columns align across rows. + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + when { + trailing != null -> trailing() + onClick != null -> Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun ListCardPreviewContent() { + ListCard { + ListRow(key = "Started", value = "Aug 6, 2026", valueEmphasis = true, onClick = {}) + HairlineDivider() + ListRow( + key = "Ended", + value = "Still ongoing", + valueEmphasis = true, + valueColor = MaterialTheme.colorScheme.primary, + onClick = {}, + ) + } + ListCard { + ListRow(key = "Weather", value = "Cloudy") + HairlineDivider() + ListRow(key = "Rainfall", value = "12 mm") + } +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun ListCardPreviewLight() { + ComponentPreviewSurface { ListCardPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun ListCardPreviewDark() { + ComponentPreviewSurface(dark = true) { ListCardPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun ListCardPreviewLarge() { + ComponentPreviewSurface { ListCardPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt b/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt new file mode 100644 index 0000000..31eed1a --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt @@ -0,0 +1,249 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.ui.util.CategoryType + +/** + * The value a [MetricInput] holds, one variant per input model. + * + * The Yes/No and Time variants are defined now so Phase 4 can add the + * matching [CategoryType] entries without reshaping this API; nothing renders + * them yet. + */ +sealed interface MetricValue { + /** Multi-select text labels (the `default` chip input). */ + data class Choice(val selected: Set) : MetricValue + + /** A discrete whole-number step on a rating scale. */ + data class Scale(val step: Int?) : MetricValue + + /** A genuine continuous reading (weight, temperature). */ + data class Continuous(val value: Float?) : MetricValue + + /** Free numeric text, kept as typed until save-time parsing. */ + data class FreeNumber(val text: String) : MetricValue + + /** A per-day tally (the `increment` input). */ + data class Count(val count: Int) : MetricValue + + /** Yes/No state; null until the user answers. Rendered from Phase 4. */ + data class YesNo(val value: Boolean?) : MetricValue + + /** A time of day as "HH:mm"; null until picked. Rendered from Phase 4. */ + data class TimeOfDay(val time: String?) : MetricValue +} + +/** + * Everything a [MetricInput] needs to render a category's input control, + * lifted from the category row by the caller. + */ +data class MetricConfig( + val name: String, + val options: List = emptyList(), + val min: Int = 1, + val max: Int = 5, + val stepLabels: Map = emptyMap(), + val unit: String? = null, + val allowDecimals: Boolean = false, + val endLabels: Pair? = null, +) + +/** + * The one input facade: screens render a metric through this and never branch + * on the category type themselves. + * + * Phase 3 stub: the switch exists and returns a working control per type, but + * full logging behaviour (save blocking, timed timelines, value formatting) + * lands in Phase 4, which is also where the `yes_no` and `time` types join + * [CategoryType]. The "kill the slider" rule is applied here: a discrete + * whole-step scale renders as a [StepScale]; only a genuinely continuous + * measure (decimals allowed, or a wide range) keeps a real slider. + */ +@Composable +fun MetricInput( + type: CategoryType, + config: MetricConfig, + value: MetricValue?, + role: Color, + onRole: Color, + onChange: (MetricValue) -> Unit, + modifier: Modifier = Modifier, +) { + when (type) { + CategoryType.DEFAULT -> ChipRow( + options = config.options, + selected = (value as? MetricValue.Choice)?.selected ?: emptySet(), + role = role, + onToggle = { option -> + val current = (value as? MetricValue.Choice)?.selected ?: emptySet() + val next = if (option in current) current - option else current + option + onChange(MetricValue.Choice(next)) + }, + modifier = modifier, + ) + + CategoryType.NUMERIC_SLIDER -> { + val stepCount = config.max - config.min + 1 + val isContinuous = config.allowDecimals || stepCount > 10 + if (isContinuous) { + val current = (value as? MetricValue.Continuous)?.value ?: config.min.toFloat() + Slider( + value = current.coerceIn(config.min.toFloat(), config.max.toFloat()), + onValueChange = { onChange(MetricValue.Continuous(it)) }, + valueRange = config.min.toFloat()..config.max.toFloat(), + colors = SliderDefaults.colors( + thumbColor = role, + activeTrackColor = role, + ), + modifier = modifier.fillMaxWidth(), + ) + } else { + val range = config.min..config.max + val labels = if (config.stepLabels.isEmpty()) null + else range.map { config.stepLabels[it] ?: it.toString() } + StepScale( + name = config.name, + range = range, + value = (value as? MetricValue.Scale)?.step, + role = role, + onRole = onRole, + onSelect = { onChange(MetricValue.Scale(it)) }, + labels = labels, + endLabels = config.endLabels, + modifier = modifier, + ) + } + } + + CategoryType.NUMERIC_FREE -> OutlinedTextField( + value = (value as? MetricValue.FreeNumber)?.text ?: "", + onValueChange = { onChange(MetricValue.FreeNumber(it)) }, + label = { Text(config.unit ?: "Value") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = modifier.fillMaxWidth(), + ) + + CategoryType.INCREMENT -> { + val count = (value as? MetricValue.Count)?.count ?: 0 + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + OutlinedIconButton( + onClick = { onChange(MetricValue.Count((count - 1).coerceAtLeast(0))) }, + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = "Decrease ${config.name}", + ) + } + Text( + text = count.toString(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + FilledIconButton( + onClick = { onChange(MetricValue.Count(count + 1)) }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = role, + contentColor = onRole, + ), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Increase ${config.name}", + ) + } + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun MetricInputPreviewContent() { + SectionHeader(label = "Symptoms", value = "1 today") + MetricInput( + type = CategoryType.DEFAULT, + config = MetricConfig(name = "Symptoms", options = listOf("Cramps", "Nausea", "Headache")), + value = MetricValue.Choice(setOf("Nausea")), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Severity", value = "3 of 5", valueColor = MaterialTheme.colorScheme.primary) + MetricInput( + type = CategoryType.NUMERIC_SLIDER, + config = MetricConfig(name = "Severity", min = 1, max = 5), + value = MetricValue.Scale(3), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Weight") + MetricInput( + type = CategoryType.NUMERIC_FREE, + config = MetricConfig(name = "Weight", unit = "kg"), + value = MetricValue.FreeNumber("72.4"), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Count", value = "6 glasses of water") + MetricInput( + type = CategoryType.INCREMENT, + config = MetricConfig(name = "Water"), + value = MetricValue.Count(6), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun MetricInputPreviewLight() { + ComponentPreviewSurface { MetricInputPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun MetricInputPreviewDark() { + ComponentPreviewSurface(dark = true) { MetricInputPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun MetricInputPreviewLarge() { + ComponentPreviewSurface { MetricInputPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt b/app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt new file mode 100644 index 0000000..efd06a0 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt @@ -0,0 +1,115 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The sticky bottom action: a full-width 52dp pill in the screen's [role] + * colour, with a gradient fade of the background over the scrolling content + * above it so list items dissolve under the bar instead of clipping. + * + * Place it bottom-aligned over the scroll container (e.g. in a Box). The + * label is always text, so no extra content description is needed. + */ +@Composable +fun PrimarySaveBar( + label: String, + role: Color, + onRole: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val background = MaterialTheme.colorScheme.background + Column(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .background( + Brush.verticalGradient( + listOf(background.copy(alpha = 0f), background) + ) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(background) + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(26.dp), + colors = ButtonDefaults.buttonColors( + containerColor = role, + contentColor = onRole, + ), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text( + text = label, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun PrimarySaveBarPreviewContent() { + PrimarySaveBar( + label = "Save log", + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onClick = {}, + ) + PrimarySaveBar( + label = "Add entry", + role = MaterialTheme.colorScheme.secondary, + onRole = MaterialTheme.colorScheme.onSecondary, + onClick = {}, + enabled = false, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun PrimarySaveBarPreviewLight() { + ComponentPreviewSurface { PrimarySaveBarPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun PrimarySaveBarPreviewDark() { + ComponentPreviewSurface(dark = true) { PrimarySaveBarPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun PrimarySaveBarPreviewLarge() { + ComponentPreviewSurface { PrimarySaveBarPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt new file mode 100644 index 0000000..13eef95 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt @@ -0,0 +1,216 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.ui.util.CATEGORY_COLOR_OPTIONS +import com.mapgie.goflo.ui.util.CategoryColor +import com.mapgie.goflo.ui.util.toCategoryColor +import com.mapgie.goflo.ui.util.toCategoryOnColor +import com.mapgie.goflo.ui.util.toHexColorKey + +/** + * The "Pick a colour" control: six in-theme role pills (which re-theme with + * the active palette) above a track of fixed swatches (deliberately exempt + * from theme changes). + * + * Factored out of the Phase 1 inline picker in ManageCategoriesScreen; that + * screen keeps its own copy until a later phase rewires it. [extraFixedSlot] + * lets the create/edit flow append its custom-colour swatch to the fixed + * track. + * + * State is hoisted: [selectedToken] is a [CategoryColor] key or an 8-char hex + * key, and [onPick] fires with the tapped token. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RolePicker( + selectedToken: String, + onPick: (String) -> Unit, + modifier: Modifier = Modifier, + roles: List = CategoryColor.entries, + fixedColors: List = CATEGORY_COLOR_OPTIONS, + extraFixedSlot: (@Composable () -> Unit)? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SectionHeader(label = "In-theme roles", value = "Re-theme automatically") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + roles.forEach { colorOption -> + RolePill( + option = colorOption, + isSelected = colorOption.key == selectedToken, + onPick = onPick, + ) + } + } + HairlineDivider() + SectionHeader(label = "Fixed colour", value = "Stays put on theme change") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + fixedColors.forEach { argb -> + FixedSwatch( + argb = argb, + isSelected = argb.toHexColorKey() == selectedToken, + onPick = onPick, + ) + } + extraFixedSlot?.invoke() + } + } +} + +@Composable +private fun RolePill( + option: CategoryColor, + isSelected: Boolean, + onPick: (String) -> Unit, +) { + val roleColor = option.key.toCategoryColor() + val onRoleColor = option.key.toCategoryOnColor() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(50)) + .then( + if (isSelected) Modifier.background(roleColor) + else Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(50)) + ) + .semantics { + this.role = Role.RadioButton + this.selected = isSelected + this.contentDescription = option.displayName + } + .clickable { onPick(option.key) } + .padding(horizontal = 14.dp), + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = onRoleColor, + modifier = Modifier.size(16.dp), + ) + } else { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(roleColor), + ) + } + Text( + text = option.displayName, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else null, + color = if (isSelected) onRoleColor else MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun FixedSwatch( + argb: Int, + isSelected: Boolean, + onPick: (String) -> Unit, +) { + val hexKey = argb.toHexColorKey() + val swatchColor = Color(argb) + val onSwatchColor = if (swatchColor.luminance() > 0.35f) Color(0xFF1C1B1F) else Color.White + // 48dp outer tap target around a 38dp visible swatch. + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .semantics { + this.role = Role.RadioButton + this.selected = isSelected + this.contentDescription = "Fixed colour $hexKey" + } + .clickable { onPick(hexKey) }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(swatchColor) + .then( + if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = onSwatchColor, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Preview(name = "Light", showBackground = true) +@Composable +private fun RolePickerPreviewLight() { + ComponentPreviewSurface { + RolePicker(selectedToken = "primary", onPick = {}) + } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun RolePickerPreviewDark() { + ComponentPreviewSurface(dark = true) { + RolePicker(selectedToken = CATEGORY_COLOR_OPTIONS.first().toHexColorKey(), onPick = {}) + } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun RolePickerPreviewLarge() { + ComponentPreviewSurface { + RolePicker(selectedToken = "quaternary", onPick = {}) + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt b/app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt new file mode 100644 index 0000000..d68d9a1 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt @@ -0,0 +1,20 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp + +/** + * Derives the tonal "container" fill for a category role colour by blending the + * accent toward the surface, mirroring the house pattern in + * [com.mapgie.goflo.ui.util.ordinalShade]. + * + * The redesign's selected states are tonal fills (chip fills, hero containers, + * segment fills). Material's ColorScheme only carries containers for its three + * built-in accents, so for an arbitrary role colour (quaternary/quinary/senary + * or a fixed hex) the container is derived here instead. Text placed on the + * result should use onSurface/onSurfaceVariant: at the default fraction the + * fill stays close enough to the surface that surface-level text contrast is + * preserved in both light and dark themes. + */ +fun roleContainerTint(role: Color, surface: Color, fraction: Float = 0.25f): Color = + lerp(surface, role, fraction.coerceIn(0f, 1f)) diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt b/app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt new file mode 100644 index 0000000..efc794a --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt @@ -0,0 +1,88 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +/** + * The small uppercase label that sits above every group of controls, with the + * group's current value right-aligned ("FLOW Medium"). + * + * Redesign rule: section titles are small uppercase labels; the current value + * lives in the header, not inside the control. Pass the category's role colour + * as [valueColor] when the value is a live reading; leave the muted default for + * meta values like "Optional" or "Not today". + */ +@Composable +fun SectionHeader( + label: String, + modifier: Modifier = Modifier, + value: String? = null, + valueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.11.em, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(Modifier.width(8.dp)) + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = valueColor, + ) + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun SectionHeaderPreviewContent() { + SectionHeader( + label = "Flow", + value = "Medium", + valueColor = MaterialTheme.colorScheme.primary, + ) + SectionHeader(label = "Notes", value = "Optional") + SectionHeader(label = "Dates") +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun SectionHeaderPreviewLight() { + ComponentPreviewSurface { SectionHeaderPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun SectionHeaderPreviewDark() { + ComponentPreviewSurface(dark = true) { SectionHeaderPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun SectionHeaderPreviewLarge() { + ComponentPreviewSurface { SectionHeaderPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt b/app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt new file mode 100644 index 0000000..1799e4e --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt @@ -0,0 +1,87 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp + +/** + * Mutually exclusive segments built on Material's single-choice segmented + * buttons (radio-button semantics and an active checkmark come built in, so + * the selected state is never colour-only). + * + * Powers the Grouped/Ungrouped management toggle and the Yes/No input type. + * Pass [role] to tint the active segment with a category's role; leave null + * for the neutral Material default (management surfaces). + */ +@Composable +fun SegmentedToggle( + options: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + role: Color? = null, +) { + SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = index == selected, + onClick = { onSelect(index) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + colors = if (role != null) { + SegmentedButtonDefaults.colors( + activeContainerColor = roleContainerTint(role, MaterialTheme.colorScheme.surface), + activeContentColor = MaterialTheme.colorScheme.onSurface, + ) + } else { + SegmentedButtonDefaults.colors() + }, + ) { + Text(option, fontSize = 13.5.sp) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun SegmentedTogglePreviewContent() { + SegmentedToggle( + options = listOf("Grouped", "Ungrouped"), + selected = 0, + onSelect = {}, + ) + SectionHeader(label = "Yes / no", value = "Yes", valueColor = MaterialTheme.colorScheme.primary) + SegmentedToggle( + options = listOf("Yes", "No"), + selected = 0, + onSelect = {}, + role = MaterialTheme.colorScheme.primary, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun SegmentedTogglePreviewLight() { + ComponentPreviewSurface { SegmentedTogglePreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun SegmentedTogglePreviewDark() { + ComponentPreviewSurface(dark = true) { SegmentedTogglePreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun SegmentedTogglePreviewLarge() { + ComponentPreviewSurface { SegmentedTogglePreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt b/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt new file mode 100644 index 0000000..0dfe800 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt @@ -0,0 +1,200 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Discrete rising tap-steps: the redesign's replacement for rating sliders. + * One component serves flow, rage, severity, and day-overall; [range], + * [labels], and [role] are the only differences between them. + * + * A single tap selects a step; there is no drag. Step height rises left to + * right so magnitude is never colour-only, and the selected step also gets a + * bold caption. Selection changes are instant (no animation), which honours + * reduce-motion by construction. + * + * Accessibility: the whole scale exposes as ONE control announcing + * ", , of ", with one custom action per step, + * instead of N unlabelled buttons. + */ +@Composable +fun StepScale( + name: String, + range: IntRange, + value: Int?, + role: Color, + onRole: Color, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + labels: List? = null, + endLabels: Pair? = null, +) { + val steps = range.toList() + val total = steps.size + val selectedIndex = value?.let { v -> steps.indexOf(v).takeIf { it >= 0 } } + + fun captionFor(index: Int): String = + labels?.getOrNull(index) ?: steps[index].toString() + + val description = if (selectedIndex == null) { + "$name, not set" + } else { + "$name, ${captionFor(selectedIndex)}, ${selectedIndex + 1} of $total" + } + val stepActions = steps.mapIndexed { index, step -> + CustomAccessibilityAction(label = "Set to ${captionFor(index)}") { + onSelect(step) + true + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .clearAndSetSemantics { + this.role = Role.RadioButton + this.contentDescription = description + this.customActions = stepActions + }, + ) { + // Tap targets fill the full 48dp row height even where the visible bar + // is shorter. + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + steps.forEachIndexed { index, step -> + val isSelected = index == selectedIndex + val rise = if (total <= 1) 1f else index.toFloat() / (total - 1) + val barHeight = (22 + 24 * rise).dp + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .semantics { this.role = Role.RadioButton } + .clickable { onSelect(step) }, + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) role + else MaterialTheme.colorScheme.surfaceVariant + ), + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + steps.forEachIndexed { index, _ -> + val isSelected = index == selectedIndex + Text( + text = captionFor(index), + fontSize = 11.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) role else MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + if (endLabels != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + ) { + Text( + text = endLabels.first, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = endLabels.second, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun StepScalePreviewContent() { + SectionHeader(label = "Flow", value = "Medium", valueColor = MaterialTheme.colorScheme.primary) + StepScale( + name = "Flow", + range = 1..4, + value = 3, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onSelect = {}, + labels = listOf("Spot", "Light", "Med", "Heavy"), + ) + SectionHeader(label = "Rage", value = "Not today") + StepScale( + name = "Rage", + range = 0..5, + value = null, + role = MaterialTheme.colorScheme.secondary, + onRole = MaterialTheme.colorScheme.onSecondary, + onSelect = {}, + endLabels = "Calm" to "Volcanic", + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun StepScalePreviewLight() { + ComponentPreviewSurface { StepScalePreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun StepScalePreviewDark() { + ComponentPreviewSurface(dark = true) { StepScalePreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun StepScalePreviewLarge() { + ComponentPreviewSurface { StepScalePreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt b/app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt new file mode 100644 index 0000000..4df18a3 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt @@ -0,0 +1,126 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A full-width toggle row: title (plus optional one-line consequence subtitle) + * on the left, a switch tinted with the category's [role] on the right. + * + * Shared by allow-multiple, log-with-period, allow-decimals, and alarm-enable. + * The whole row is the tap target and carries switch semantics with an + * explicit On/Off state description; the inner Switch has no click handler of + * its own, so TalkBack sees exactly one focusable control. + */ +@Composable +fun SwitchRow( + title: String, + checked: Boolean, + role: Color, + onRole: Color, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { stateDescription = if (checked) "On" else "Off" } + .toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (subtitle != null) { + Text( + text = subtitle, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = checked, + onCheckedChange = null, + colors = SwitchDefaults.colors( + checkedTrackColor = role, + checkedThumbColor = onRole, + ), + ) + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun SwitchRowPreviewContent() { + ListCard { + SwitchRow( + title = "Allow multiple per day", + subtitle = "Log it several times, each keeps the time of entry", + checked = true, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = {}, + ) + HairlineDivider() + SwitchRow( + title = "Log with period", + subtitle = "Surface it in the flow context while a period runs", + checked = false, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = {}, + ) + } +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun SwitchRowPreviewLight() { + ComponentPreviewSurface { SwitchRowPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun SwitchRowPreviewDark() { + ComponentPreviewSurface(dark = true) { SwitchRowPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun SwitchRowPreviewLarge() { + ComponentPreviewSurface { SwitchRowPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt b/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt new file mode 100644 index 0000000..b0b813b --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt @@ -0,0 +1,231 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * One appended reading in a multiple-per-day [Timeline]: + * "08:10 Level 2 mild". The time is always shown as text, so the entry is + * parseable without colour (colour-blind and greyscale safe). + */ +data class TimelineEntryData( + val id: Long, + val time: String, + val value: String, + val sub: String? = null, +) + +/** + * The multiple-per-day list: when a category allows multiple entries, the day + * becomes a card of timestamped readings plus an append row. Domain state is + * hoisted; the component only renders [entries] and reports taps. + * + * [appendHint] is the small right-aligned helper on the append row, typically + * the current time ("now, 21:30"). Pass [onEditEntry]/[onDeleteEntry] to give + * each row an overflow menu. + */ +@Composable +fun Timeline( + entries: List, + role: Color, + onAppend: () -> Unit, + modifier: Modifier = Modifier, + appendLabel: String = "Log another", + appendHint: String? = null, + onEditEntry: ((TimelineEntryData) -> Unit)? = null, + onDeleteEntry: ((TimelineEntryData) -> Unit)? = null, +) { + ListCard(modifier = modifier) { + entries.forEach { entry -> + TimelineEntry( + time = entry.time, + value = entry.value, + role = role, + sub = entry.sub, + onEdit = onEditEntry?.let { edit -> { edit(entry) } }, + onDelete = onDeleteEntry?.let { delete -> { delete(entry) } }, + ) + HairlineDivider() + } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { this.role = Role.Button } + .clickable(onClick = onAppend) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = role, + modifier = Modifier.size(18.dp), + ) + Text( + text = appendLabel, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = role, + modifier = Modifier.weight(1f), + ) + if (appendHint != null) { + Text( + text = appendHint, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * One row of a [Timeline]: leading time in the category's [role] colour, + * value plus optional sub-line, and an overflow menu when [onEdit] or + * [onDelete] is provided. Menu visibility is transient presentation state and + * lives inside the row; domain state stays hoisted. + */ +@Composable +fun TimelineEntry( + time: String, + value: String, + role: Color, + modifier: Modifier = Modifier, + sub: String? = null, + onEdit: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = time, + fontSize = 13.5.sp, + fontWeight = FontWeight.SemiBold, + color = role, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (sub != null) { + Text( + text = sub, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (onEdit != null || onDelete != null) { + Box { + var menuOpen by remember { mutableStateOf(false) } + IconButton(onClick = { menuOpen = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Options for the $time entry", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (onEdit != null) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + menuOpen = false + onEdit() + }, + ) + } + if (onDelete != null) { + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + menuOpen = false + onDelete() + }, + ) + } + } + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun TimelinePreviewContent() { + SectionHeader(label = "Today", value = "3 logged") + Timeline( + entries = listOf( + TimelineEntryData(id = 1, time = "08:10", value = "Level 2", sub = "mild"), + TimelineEntryData(id = 2, time = "13:40", value = "Level 4", sub = "after lunch"), + TimelineEntryData(id = 3, time = "19:05", value = "Level 1", sub = "faded"), + ), + role = MaterialTheme.colorScheme.primary, + onAppend = {}, + appendHint = "now, 21:30", + onEditEntry = {}, + onDeleteEntry = {}, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun TimelinePreviewLight() { + ComponentPreviewSurface { TimelinePreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun TimelinePreviewDark() { + ComponentPreviewSurface(dark = true) { TimelinePreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun TimelinePreviewLarge() { + ComponentPreviewSurface { TimelinePreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt new file mode 100644 index 0000000..4338723 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt @@ -0,0 +1,113 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.ui.theme.ComfortaaFamily + +/** + * The tonal container that makes one metric the page: a big reading as words + * ("Barely noticeable", "Pretty good") on a container tint of the metric's + * [role] (blue for facts, amber for feelings; the user's group choice). + * + * The hero word uses the Comfortaa brand family, applied explicitly because + * GoFloTypography does not wire it in. The word itself is onSurface so it + * stays readable on the tint in every palette; the role communicates through + * the container. + * + * [content] slots the metric's input control (typically a [StepScale]) under + * the word. + */ +@Composable +fun ToneHero( + word: String, + role: Color, + modifier: Modifier = Modifier, + caption: String? = null, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + val container = roleContainerTint(role, MaterialTheme.colorScheme.surface) + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(container) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = word, + fontFamily = ComfortaaFamily, + fontWeight = FontWeight.Bold, + fontSize = 23.sp, + lineHeight = 30.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + if (caption != null) { + Text( + text = caption, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + content?.invoke(this) + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun ToneHeroPreviewContent() { + ToneHero( + word = "Barely noticeable", + role = MaterialTheme.colorScheme.primary, + caption = "How bad is it right now?", + ) { + StepScale( + name = "Nose symptoms", + range = 1..5, + value = 1, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onSelect = {}, + endLabels = "Barely there" to "Unbearable", + ) + } + ToneHero( + word = "Pretty good", + role = MaterialTheme.colorScheme.secondary, + caption = "How did today feel overall?", + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun ToneHeroPreviewLight() { + ComponentPreviewSurface { ToneHeroPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun ToneHeroPreviewDark() { + ComponentPreviewSurface(dark = true) { ToneHeroPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun ToneHeroPreviewLarge() { + ComponentPreviewSurface { ToneHeroPreviewContent() } +} diff --git a/changelog/unreleased/logging-redesign-phase-3.json b/changelog/unreleased/logging-redesign-phase-3.json new file mode 100644 index 0000000..145d789 --- /dev/null +++ b/changelog/unreleased/logging-redesign-phase-3.json @@ -0,0 +1,4 @@ +{ + "bump": "patch", + "changed": ["Internal: shared UI component library for the logging redesign (no user-facing change yet)"] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index e6596f4..baa754c 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -205,7 +205,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 0 — Planning & groundwork | Done | this branch (`claude/design-handover-bdq9he`) | 23 | Subsystem maps stamped at `d07d947` / vc116. Theme-reconciliation split out as separate task. | | 1 — Extended colour roles | Done | `claude/logging-redesign-phase-1-f08e0o` | 23 | Derivation deviates from the sketch in two ways: near-greyscale accents (High Contrast) shift lightness instead of hue (hue rotation of grey is a no-op), and on-colours pick near-black vs white by max contrast rather than a 0.35 luminance threshold (the threshold has a 0.30-0.35 band where white fails 3:1). Spot-check script `wcag_check_roles.py` added (all 12 families + HC + Blue & Orange, worst ratio 4.24:1). Also restored `wcag_check.py`, which was committed as one base64 line in `84624bc`. | | 2 — Group data model | Done | `claude/logging-redesign-phase-2-nuaywv` | 24 | Colour-inheritance deviation (keep existing colours, opt-in `"inherit"`) confirmed with owner. `"inherit"` is a sentinel constant, not a `CategoryColor` entry, so the Phase 1 picker does not offer it. Group methods live in `TrackingRepository` (new nullable `groupDao` ctor param) rather than a separate repository. No FK on `groupId`; `deleteGroup` unfiles members first. Migration test is a JVM test (`Migration23To24Test`) driving the real migration through sqlite-jdbc, since the project has no instrumented tests and `exportSchema = false`. | -| 3 — Component library | Not started | | | | +| 3 — Component library | Done | `claude/logging-redesign-phase-3` | 24 (unchanged) | 12 primitives + `MetricInput` stub in `ui/components/`, previews only — no screen consumes them yet. Deviations: `RolePicker` built standalone (visuals mirrored from the Phase 1 picker; `ManageCategoriesScreen` left untouched per this phase's "existing screens compile unchanged" rule — rewire in Phase 6/7); tonal container fills for arbitrary role colours derived via `roleContainerTint` (lerp toward surface, house `ordinalShade` pattern) since M3 `ColorScheme` has no containers for derived/fixed roles; `ToneHero` word uses onSurface on the tint (contrast-safe in every palette) with the role carried by the container; `StepScale` gained a `name` param for its single-control announcement plus per-step `customActions` so TalkBack can still operate it. `MetricValue`/`MetricConfig` value types defined now, incl. YesNo/TimeOfDay variants for Phase 4. | | 4 — MetricInput + Yes/No + Time | Not started | | | | | 5 — Unified LogScreen | Not started | | | Consider sub-PRs. | | 6 — What You Track home | Not started | | | | From d93476ebfbd4074e66e7175bad69be9a8fa9b811 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:58:04 +0000 Subject: [PATCH 3/8] Phase 4: MetricInput facade + Yes/No and Time input types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the MetricInput facade so it renders every category input type, and refactors LogCategoryScreen to render through it with behaviour parity: - CategoryType gains YES_NO ("yes_no") and TIME ("time"). Both store their readings as plain value-label strings ("Yes"/"No"; 24-hour "HH:mm") in tracking_log_values per the owner's resolution of PLAN.md §8 decision #3: no new columns, no migration (DB stays at v24). - MetricInput: whole-step rating scales (up to 10 steps) render as StepScale; decimal or wider ranges keep the parity slider including whole-number stepped behaviour, large readout, scale labels, min/max labels, and the unset-value hint. numeric_free keeps unit label + placeholder; increment keeps the never-below-zero counter (decrement disabled at 0); yes_no renders a role-tinted SegmentedToggle; time renders the new TimeField primitive (Material time picker, 24h). - LogCategoryScreen: the inline per-type when is gone; every non-timed input renders via MetricInput. Timed increment stays screen-driven (per-tap immediate saves, no Save button) and now renders the Timeline primitive. Notes 500-char cap, save/update, delete, date selection, edit-existing, empty-catalog text, and "previously recorded (removed)" chips unchanged. - LogCategoryViewModel: additive setSelectedValues; yes_no/time flow through selectedValues, so the existing else-branch save path persists them and the numeric_free empty-blocks-save / increment <=0-blocks-save rules are untouched. - LogPeriodScreen: PinnedCategoryInput gains additive yes_no/time branches delegating to MetricInput (pre-existing branches untouched); additive LogPeriodViewModel.setPinnedSingleValue feeds the existing selection-set save path. - TrackingCategory.isNumeric now enumerates the numeric types explicitly so yes_no/time chart as label categories in Stats instead of being fed into numeric chart math. - Create flow: the two new types appear automatically in the New Category type chips; the unit field is now limited to the genuinely numeric types. - Docs: PLAN.md §7 row + §8 #3 resolution, subsystem map 01 drift note, LESSONS.md entry on negation-defined classifications, changelog fragment (minor). Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- LESSONS.md | 3 + .../database/entities/TrackingCategory.kt | 18 +- .../mapgie/goflo/ui/components/MetricInput.kt | 251 +++++++-- .../mapgie/goflo/ui/components/TimeField.kt | 170 ++++++ .../categories/ManageCategoriesScreen.kt | 6 +- .../goflo/ui/screens/log/LogCategoryScreen.kt | 482 ++++++------------ .../ui/screens/log/LogCategoryViewModel.kt | 11 + .../goflo/ui/screens/log/LogPeriodScreen.kt | 41 ++ .../ui/screens/log/LogPeriodViewModel.kt | 13 + .../goflo/ui/util/CategoryAppearance.kt | 2 + .../logging-yesno-time-metricinput.json | 10 + docs/design/logging-redesign/PLAN.md | 4 +- .../subsystem-maps/01-logging-screens.md | 2 + 13 files changed, 653 insertions(+), 360 deletions(-) create mode 100644 app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt create mode 100644 changelog/unreleased/logging-yesno-time-metricinput.json diff --git a/LESSONS.md b/LESSONS.md index e1b2aa5..14a48ac 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -32,6 +32,9 @@ When a SmallFloatingActionButton contains an Icon with `contentDescription = nul **`ModalBottomSheetProperties` requires all parameters explicitly in Material3 1.2.x** The constructor has no default values in this version — passing only `shouldDismissOnBackPress` fails to compile. Always supply all three: `securePolicy = SecureFlagPolicy.Inherit, isFocusable = true, shouldDismissOnBackPress = false`. `SecureFlagPolicy` also needs an explicit import from `androidx.compose.ui.window`. +**A classification defined by negation ("anything but X") silently misclassifies new variants** +`TrackingCategory.isNumeric` was `categoryType != "default"`, which was correct while every non-default type happened to store numbers. Adding the label-valued "yes_no" and "time" types would have silently routed "Yes"/"HH:mm" strings into numeric chart math (`toFloatOrNull()` returning null everywhere) with no compile error, because a negated predicate auto-includes every future variant. When a derived property gates behaviour, define membership positively (enumerate the types that ARE numeric); then a new variant defaults to the safe side and the property's KDoc records why. Grep for `!=` against discriminator fields whenever adding a variant to a string-keyed or enum type. + **Parallel write paths must each respect every category setting** When two code paths write to the same store (e.g. `LogPeriodViewModel.syncSymptomsToTrackingLog` and `LogCategoryViewModel.save` both writing to `tracking_logs`), each path must independently read and apply every relevant category flag. If a new flag is added (like `trackAgainstTime`) and only one path is updated, the other silently ignores the setting. When adding a per-category behaviour flag, grep for all call sites of the underlying `saveLog` / `updateLogInPlace` and confirm they all handle the new flag. diff --git a/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt b/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt index 04ba7be..6905f0a 100644 --- a/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt +++ b/app/src/main/java/com/mapgie/goflo/data/database/entities/TrackingCategory.kt @@ -29,8 +29,11 @@ import androidx.room.PrimaryKey * [groupId] optionally files this category under a [Group]. Nullable, no * foreign key: deleting a group unfiles its members rather than cascading. * - * [categoryType] is one of "default" | "numeric_slider" | "numeric_free" | "increment" - * (see [com.mapgie.goflo.ui.util.CategoryType]). It is immutable after creation. + * [categoryType] is one of "default" | "numeric_slider" | "numeric_free" | + * "increment" | "yes_no" | "time" (see [com.mapgie.goflo.ui.util.CategoryType]). + * It is immutable after creation. The "yes_no" and "time" types store their + * readings as plain value-label strings ("Yes"/"No" and 24-hour "HH:mm"), so + * they need no schema support beyond [TrackingLogValue]. * * [numericUnit] is an optional suffix shown alongside numeric values (e.g. "°C"). * @@ -66,5 +69,14 @@ data class TrackingCategory( @ColumnInfo(defaultValue = "") val modeKey: String = "", val groupId: Long? = null, ) { - val isNumeric: Boolean get() = categoryType != "default" + /** + * Whether this category's stored value labels parse as numbers (drives the + * numeric chart types in Stats). Enumerated explicitly rather than + * "anything but default" because "yes_no" and "time" store non-numeric + * labels ("Yes"/"No", "HH:mm") and must chart as label categories. + */ + val isNumeric: Boolean + get() = categoryType == "numeric_slider" || + categoryType == "numeric_free" || + categoryType == "increment" } diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt b/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt index 31eed1a..03af38e 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt @@ -1,8 +1,10 @@ package com.mapgie.goflo.ui.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -31,9 +33,14 @@ import com.mapgie.goflo.ui.util.CategoryType /** * The value a [MetricInput] holds, one variant per input model. * - * The Yes/No and Time variants are defined now so Phase 4 can add the - * matching [CategoryType] entries without reshaping this API; nothing renders - * them yet. + * ### Storage encoding (PLAN.md §8 decision #3, resolved by the owner) + * Every variant round-trips to the same store as before: plain value-label + * strings in `tracking_log_values`. The two Phase 4 types follow suit with + * NO new columns and NO migration: + * - [YesNo] persists as the literal label "Yes" or "No". + * - [TimeOfDay] persists as a 24-hour "HH:mm" label. + * Stats already counts value labels, so Yes/No charts work for free; a time + * label is display-only in Stats (accepted). */ sealed interface MetricValue { /** Multi-select text labels (the `default` chip input). */ @@ -51,10 +58,10 @@ sealed interface MetricValue { /** A per-day tally (the `increment` input). */ data class Count(val count: Int) : MetricValue - /** Yes/No state; null until the user answers. Rendered from Phase 4. */ + /** Yes/No state; null until the user answers. Stored as "Yes"/"No". */ data class YesNo(val value: Boolean?) : MetricValue - /** A time of day as "HH:mm"; null until picked. Rendered from Phase 4. */ + /** A time of day as "HH:mm"; null until picked. Stored as "HH:mm". */ data class TimeOfDay(val time: String?) : MetricValue } @@ -73,16 +80,42 @@ data class MetricConfig( val endLabels: Pair? = null, ) +/** + * Whether a `numeric_slider` category renders as discrete tap-steps + * ([StepScale]) rather than a continuous [Slider]. The "kill the slider" rule: + * a whole-step scale of up to 10 steps is a rating and gets tap-steps; a + * decimal or wider range is a genuine measure and keeps a real slider. + * + * Exposed so callers (the log screens) can decide which [MetricValue] variant + * mirrors their state without duplicating the threshold. + */ +fun MetricConfig.usesStepScale(): Boolean = + !allowDecimals && (max - min + 1) in 2..10 + +/** Reads the numeric reading out of whichever slider-family variant holds it. */ +private fun MetricValue?.numericOrNull(): Float? = when (this) { + is MetricValue.Scale -> step?.toFloat() + is MetricValue.Continuous -> value + is MetricValue.Count -> count.toFloat() + else -> null +} + /** * The one input facade: screens render a metric through this and never branch * on the category type themselves. * - * Phase 3 stub: the switch exists and returns a working control per type, but - * full logging behaviour (save blocking, timed timelines, value formatting) - * lands in Phase 4, which is also where the `yes_no` and `time` types join - * [CategoryType]. The "kill the slider" rule is applied here: a discrete - * whole-step scale renders as a [StepScale]; only a genuinely continuous - * measure (decimals allowed, or a wide range) keeps a real slider. + * Behaviour parity with the pre-redesign per-type sections is the contract: + * - `numeric_slider`: whole-step behaviour is kept (tap-steps for ratings, + * a stepped slider for whole ranges wider than 10, continuous only with + * decimals), scale labels still caption the steps, and an unset value still + * displays as the range minimum (which is also what saves). + * - `numeric_free`: unit label + decimal keyboard; the caller keeps its + * empty-input-blocks-save rule (the facade never fabricates a value). + * - `increment`: count never drops below 0; the caller keeps its + * count-of-zero-blocks-save rule. Timed increment (per-tap immediate saves) + * is a screen-level flow rendered with [Timeline], not through this facade. + * - `yes_no` / `time`: new in Phase 4, see [MetricValue] for the storage + * encoding. */ @Composable fun MetricInput( @@ -108,28 +141,14 @@ fun MetricInput( ) CategoryType.NUMERIC_SLIDER -> { - val stepCount = config.max - config.min + 1 - val isContinuous = config.allowDecimals || stepCount > 10 - if (isContinuous) { - val current = (value as? MetricValue.Continuous)?.value ?: config.min.toFloat() - Slider( - value = current.coerceIn(config.min.toFloat(), config.max.toFloat()), - onValueChange = { onChange(MetricValue.Continuous(it)) }, - valueRange = config.min.toFloat()..config.max.toFloat(), - colors = SliderDefaults.colors( - thumbColor = role, - activeTrackColor = role, - ), - modifier = modifier.fillMaxWidth(), - ) - } else { + if (config.usesStepScale()) { val range = config.min..config.max val labels = if (config.stepLabels.isEmpty()) null else range.map { config.stepLabels[it] ?: it.toString() } StepScale( name = config.name, range = range, - value = (value as? MetricValue.Scale)?.step, + value = value.numericOrNull()?.toInt(), role = role, onRole = onRole, onSelect = { onChange(MetricValue.Scale(it)) }, @@ -137,13 +156,22 @@ fun MetricInput( endLabels = config.endLabels, modifier = modifier, ) + } else { + ContinuousSlider( + config = config, + value = value.numericOrNull(), + role = role, + onChange = { onChange(MetricValue.Continuous(it)) }, + modifier = modifier, + ) } } CategoryType.NUMERIC_FREE -> OutlinedTextField( value = (value as? MetricValue.FreeNumber)?.text ?: "", onValueChange = { onChange(MetricValue.FreeNumber(it)) }, - label = { Text(config.unit ?: "Value") }, + label = { Text(if (config.unit.isNullOrBlank()) "Value" else config.unit) }, + placeholder = { Text("Enter a number") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = modifier.fillMaxWidth(), @@ -158,19 +186,33 @@ fun MetricInput( ) { OutlinedIconButton( onClick = { onChange(MetricValue.Count((count - 1).coerceAtLeast(0))) }, + enabled = count > 0, ) { Icon( imageVector = Icons.Default.Remove, contentDescription = "Decrease ${config.name}", ) } - Text( - text = count.toString(), - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - style = TextStyle(fontFeatureSettings = "tnum"), - ) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = count.toString(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + if (!config.unit.isNullOrBlank()) { + Text( + text = config.unit, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + } FilledIconButton( onClick = { onChange(MetricValue.Count(count + 1)) }, colors = IconButtonDefaults.filledIconButtonColors( @@ -185,6 +227,116 @@ fun MetricInput( } } } + + CategoryType.YES_NO -> SegmentedToggle( + options = listOf("Yes", "No"), + selected = when ((value as? MetricValue.YesNo)?.value) { + true -> 0 + false -> 1 + null -> -1 + }, + onSelect = { onChange(MetricValue.YesNo(it == 0)) }, + role = role, + modifier = modifier, + ) + + // Label stays the generic "Time": both log screens already frame the + // input with the category's name, so repeating it would read doubled. + CategoryType.TIME -> TimeField( + value = (value as? MetricValue.TimeOfDay)?.time, + role = role, + onChange = { onChange(MetricValue.TimeOfDay(it)) }, + modifier = modifier, + ) + } +} + +/** + * The slider kept for genuine measures: continuous when decimals are allowed, + * whole-number stepped when the range is wider than [StepScale] comfortably + * fits. Mirrors the pre-redesign slider exactly: large readout (falling back + * to the range minimum, which is also the value that saves), optional scale + * label for the current whole value, min/max end labels, and a hint until the + * user sets a value. + */ +@Composable +private fun ContinuousSlider( + config: MetricConfig, + value: Float?, + role: Color, + onChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + val min = config.min.toFloat() + val max = config.max.toFloat() + val sliderValue = (value ?: min).coerceIn(min, max) + + // Steps: 0 = continuous (for decimals), otherwise whole-number steps. + val steps = if (config.allowDecimals) 0 else { + val range = (max - min).toInt() + if (range > 1) range - 1 else 0 + } + + fun format(v: Float): String = + if (config.allowDecimals) "%.1f".format(v) else v.toInt().toString() + + val scaleLabel = if (!config.allowDecimals) config.stepLabels[sliderValue.toInt()] else null + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + ) { + Text( + text = if (config.unit.isNullOrBlank()) format(sliderValue) + else "${format(sliderValue)} ${config.unit}", + style = MaterialTheme.typography.headlineMedium, + color = role, + ) + if (scaleLabel != null) { + Text( + text = scaleLabel, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Slider( + value = sliderValue, + onValueChange = onChange, + valueRange = min..max, + steps = steps, + colors = SliderDefaults.colors( + thumbColor = role, + activeTrackColor = role, + ), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = format(min), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (value == null) { + Text( + text = "Drag to set a value", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = format(max), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -210,6 +362,15 @@ private fun MetricInputPreviewContent() { onRole = MaterialTheme.colorScheme.onPrimary, onChange = {}, ) + SectionHeader(label = "Temperature") + MetricInput( + type = CategoryType.NUMERIC_SLIDER, + config = MetricConfig(name = "Temperature", min = 35, max = 39, allowDecimals = true, unit = "C"), + value = MetricValue.Continuous(36.6f), + role = MaterialTheme.colorScheme.tertiary, + onRole = MaterialTheme.colorScheme.onTertiary, + onChange = {}, + ) SectionHeader(label = "Weight") MetricInput( type = CategoryType.NUMERIC_FREE, @@ -222,12 +383,30 @@ private fun MetricInputPreviewContent() { SectionHeader(label = "Count", value = "6 glasses of water") MetricInput( type = CategoryType.INCREMENT, - config = MetricConfig(name = "Water"), + config = MetricConfig(name = "Water", unit = "glasses"), value = MetricValue.Count(6), role = MaterialTheme.colorScheme.primary, onRole = MaterialTheme.colorScheme.onPrimary, onChange = {}, ) + SectionHeader(label = "Took medication", value = "Yes", valueColor = MaterialTheme.colorScheme.primary) + MetricInput( + type = CategoryType.YES_NO, + config = MetricConfig(name = "Took medication"), + value = MetricValue.YesNo(true), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Woke up", value = "07:45", valueColor = MaterialTheme.colorScheme.secondary) + MetricInput( + type = CategoryType.TIME, + config = MetricConfig(name = "Woke up"), + value = MetricValue.TimeOfDay("07:45"), + role = MaterialTheme.colorScheme.secondary, + onRole = MaterialTheme.colorScheme.onSecondary, + onChange = {}, + ) } @Preview(name = "Light", showBackground = true) diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt b/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt new file mode 100644 index 0000000..bdb828a --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt @@ -0,0 +1,170 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +private val storageFormat = DateTimeFormatter.ofPattern("HH:mm") + +/** + * Tap-to-pick time-of-day input for the "time" category type. Shows the picked + * time (24-hour "HH:mm", exactly the stored value-label string) or a hint when + * unset, and opens a Material time picker dialog on tap. + * + * State is hoisted: [value] is the stored "HH:mm" string or null, [onChange] + * fires with the newly picked "HH:mm". The whole field is one Button-role + * control announcing its label and current value. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeField( + value: String?, + role: Color, + onChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "Time", +) { + var showPicker by rememberSaveable { mutableStateOf(false) } + + if (showPicker) { + val initial = value?.let { runCatching { LocalTime.parse(it, storageFormat) }.getOrNull() } + ?: LocalTime.now() + val pickerState = rememberTimePickerState( + initialHour = initial.hour, + initialMinute = initial.minute, + is24Hour = true, + ) + AlertDialog( + onDismissRequest = { showPicker = false }, + title = { Text("Select time") }, + text = { TimePicker(state = pickerState) }, + confirmButton = { + TextButton(onClick = { + showPicker = false + onChange("%02d:%02d".format(pickerState.hour, pickerState.minute)) + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showPicker = false }) { Text("Cancel") } + }, + ) + } + + Surface( + modifier = modifier + .fillMaxWidth() + .semantics { this.role = Role.Button } + .clickable { showPicker = true }, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier + .heightIn(min = 52.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = null, + tint = role, + modifier = Modifier.size(20.dp), + ) + Text( + text = label, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Text( + text = value, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = role, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } else { + Text( + text = "Tap to set", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun TimeFieldPreviewContent() { + SectionHeader(label = "Woke up", value = "07:45", valueColor = MaterialTheme.colorScheme.primary) + TimeField( + value = "07:45", + role = MaterialTheme.colorScheme.primary, + onChange = {}, + label = "Woke up", + ) + SectionHeader(label = "Bedtime") + TimeField( + value = null, + role = MaterialTheme.colorScheme.secondary, + onChange = {}, + label = "Bedtime", + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun TimeFieldPreviewLight() { + ComponentPreviewSurface { TimeFieldPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun TimeFieldPreviewDark() { + ComponentPreviewSurface(dark = true) { TimeFieldPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun TimeFieldPreviewLarge() { + ComponentPreviewSurface { TimeFieldPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt index 7556bed..1432425 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt @@ -694,7 +694,11 @@ private fun AddCategoryDialog( var allowMultiple by rememberSaveable { mutableStateOf(false) } var showInLogPeriod by rememberSaveable { mutableStateOf(false) } - val isNumericType = selectedType != CategoryType.DEFAULT.key + // Only the numeric family carries a unit; Yes/No and Time store fixed + // labels ("Yes"/"No", "HH:mm") and need no extra configuration at all. + val isNumericType = selectedType == CategoryType.NUMERIC_SLIDER.key || + selectedType == CategoryType.NUMERIC_FREE.key || + selectedType == CategoryType.INCREMENT.key // Only the slider type uses a min/max range — free input and increment do not. val isSliderType = selectedType == CategoryType.NUMERIC_SLIDER.key diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt index 79b35cd..ec8ef18 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt @@ -1,29 +1,22 @@ package com.mapgie.goflo.ui.screens.log +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -32,14 +25,11 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState @@ -55,12 +45,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.ui.components.MetricConfig +import com.mapgie.goflo.ui.components.MetricInput +import com.mapgie.goflo.ui.components.MetricValue import com.mapgie.goflo.ui.components.SelectableChip +import com.mapgie.goflo.ui.components.Timeline +import com.mapgie.goflo.ui.components.TimelineEntryData +import com.mapgie.goflo.ui.components.usesStepScale +import com.mapgie.goflo.ui.util.CategoryType import com.mapgie.goflo.ui.util.decodeScaleLabels +import com.mapgie.goflo.ui.util.toCategoryType import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -133,202 +129,55 @@ private fun DatePickerDialogWrapper( } } -/** - * Slider input for numeric tracking categories. - * - * Shows the category's configured range, a Material3 [Slider] constrained to - * [category.numericMin]..[category.numericMax], and the current value in large text. - * When [value] is null (no value set yet) the slider defaults to [category.numericMin] - * and shows a gentle hint. - */ -@Composable -private fun NumericSliderSection( - category: TrackingCategory, - value: Float?, - onValueChange: (Float) -> Unit, -) { - val min = category.numericMin - val max = category.numericMax - val sliderValue = value ?: min - - // Steps: 0 = continuous (for decimals), otherwise whole-number steps - val steps = if (category.allowDecimals) 0 else { - val range = (max - min).toInt() - if (range > 1) range - 1 else 0 - } - - val displayValue = if (category.allowDecimals) - "%.1f".format(sliderValue) - else - sliderValue.toInt().toString() - - val minLabel = if (category.allowDecimals) "%.1f".format(min) else min.toInt().toString() - val maxLabel = if (category.allowDecimals) "%.1f".format(max) else max.toInt().toString() - - // Optional label for the current whole-number value (e.g. 3 → "Neutral") - val scaleLabel = if (!category.allowDecimals) - category.scaleLabels.decodeScaleLabels()[sliderValue.toInt()] - else null - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - category.name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Column(horizontalAlignment = Alignment.End) { - Text( - displayValue, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary - ) - if (scaleLabel != null) { - Text( - scaleLabel, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - Slider( - value = sliderValue, - onValueChange = onValueChange, - valueRange = min..max, - steps = steps, - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(minLabel, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - if (value == null) { - Text( - "Drag to set a value", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text(maxLabel, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -@Composable -private fun NumericFreeInputSection( +/** Builds the [MetricConfig] the [MetricInput] facade renders from a category row. */ +private fun metricConfigFor( category: TrackingCategory, - value: String, - onValueChange: (String) -> Unit, -) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - category.name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { - val label = if (category.numericUnit.isNotBlank()) category.numericUnit else "Value" - Text(label) - }, - placeholder = { Text("Enter a number") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - } - } -} + availableValues: List, +): MetricConfig = MetricConfig( + name = category.name, + options = availableValues, + min = category.numericMin.toInt(), + max = category.numericMax.toInt(), + stepLabels = category.scaleLabels.decodeScaleLabels(), + unit = category.numericUnit.takeIf { it.isNotBlank() }, + allowDecimals = category.allowDecimals, +) /** - * One-tap counter input for "increment" (Plus One) categories. - * - * Shows the running count for the day with a prominent "Add one" button and a - * smaller decrement control to correct mistakes. The count is held in the - * view-model's numericValue and persisted as a whole number on save. + * Maps the screen state onto the [MetricValue] variant [MetricInput] expects + * for [type]. Yes/No and Time reuse [LogCategoryUiState.selectedValues] as a + * single-label set, matching how their readings are stored ("Yes"/"No", + * "HH:mm" value labels). */ -@Composable -private fun IncrementSection( - category: TrackingCategory, - count: Int, - onIncrement: () -> Unit, - onDecrement: () -> Unit, -) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - category.name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - count.toString(), - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary - ) - if (category.numericUnit.isNotBlank()) { - Text( - category.numericUnit, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onDecrement, enabled = count > 0) { - Icon(Icons.Default.Remove, contentDescription = "Decrease") - } - Button(onClick = onIncrement) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Add one") - } - } +private fun metricValueFor( + type: CategoryType, + config: MetricConfig, + state: LogCategoryUiState, +): MetricValue = when (type) { + CategoryType.DEFAULT -> MetricValue.Choice(state.selectedValues) + CategoryType.NUMERIC_SLIDER -> + if (config.usesStepScale()) MetricValue.Scale(state.numericValue?.toInt()) + else MetricValue.Continuous(state.numericValue) + CategoryType.NUMERIC_FREE -> MetricValue.FreeNumber(state.numericFreeText) + CategoryType.INCREMENT -> MetricValue.Count(state.numericValue?.toInt() ?: 0) + CategoryType.YES_NO -> MetricValue.YesNo( + when { + "Yes" in state.selectedValues -> true + "No" in state.selectedValues -> false + else -> null } - } + ) + CategoryType.TIME -> MetricValue.TimeOfDay(state.selectedValues.firstOrNull()) } /** - * Timed increment section for "Plus One" categories with trackAgainstTime enabled. - * Each tap immediately records a new log entry with the current time. + * Timed increment ("Plus One" + track against time): each append saves a new + * timestamped log immediately, so the day renders as a running total plus a + * [Timeline] of today's entries with per-entry delete. There is deliberately + * no notes field or Save button on this path. */ @Composable -private fun TimedIncrementSection( +private fun TimedIncrementTimeline( category: TrackingCategory, entries: List, onAddOne: () -> Unit, @@ -340,21 +189,19 @@ private fun TimedIncrementSection( .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( category.name, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - - val total = entries.size Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - total.toString(), + entries.size.toString(), style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary ) @@ -367,47 +214,23 @@ private fun TimedIncrementSection( ) } } - - Button(onClick = onAddOne) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Log +1 now") - } - - if (entries.isNotEmpty()) { - HorizontalDivider() - Text( - "Logged today:", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - entries.forEach { entry -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = entry.log.loggedAt.ifEmpty { "–" }, - style = MaterialTheme.typography.bodyMedium, - fontStyle = if (entry.log.loggedAt.isEmpty()) FontStyle.Italic else FontStyle.Normal - ) - IconButton( - onClick = { onDeleteEntry(entry.log) }, - modifier = Modifier.size(32.dp) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Remove", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } } } + Timeline( + entries = entries.map { entry -> + TimelineEntryData( + id = entry.log.id, + time = entry.log.loggedAt.ifEmpty { "No time" }, + value = "+1", + ) + }, + role = MaterialTheme.colorScheme.primary, + onAppend = onAddOne, + appendLabel = "Log +1 now", + onDeleteEntry = { data -> + entries.firstOrNull { it.log.id == data.id }?.let { onDeleteEntry(it.log) } + }, + ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -494,100 +317,123 @@ fun LogCategoryScreen( ) } - // ── Input area — slider / free input / chips ────────────────────── + // ── Input area — everything renders through the MetricInput facade; + // the timed-increment timeline is the one screen-level flow (it + // saves per tap rather than collect-then-save). ──────────────── val cat = state.category - when { - cat?.categoryType == "numeric_slider" -> { - NumericSliderSection( - category = cat, - value = state.numericValue, - onValueChange = viewModel::setNumericValue - ) - } - cat?.categoryType == "numeric_free" -> { - NumericFreeInputSection( - category = cat, - value = state.numericFreeText, - onValueChange = viewModel::setNumericFreeText - ) - } - cat?.categoryType == "increment" && cat.trackAgainstTime -> { - // Timed increment: each +1 saves immediately with timestamp - TimedIncrementSection( + val type = cat?.categoryType?.toCategoryType() ?: CategoryType.DEFAULT + val isTimedIncrement = + cat != null && type == CategoryType.INCREMENT && cat.trackAgainstTime + + if (cat != null) { + if (isTimedIncrement) { + TimedIncrementTimeline( category = cat, entries = state.timedEntriesToday, onAddOne = viewModel::addTimedIncrement, onDeleteEntry = viewModel::deleteTimedEntry ) - } - cat?.categoryType == "increment" -> { - IncrementSection( - category = cat, - count = state.numericValue?.toInt() ?: 0, - onIncrement = { viewModel.setNumericValue((state.numericValue ?: 0f) + 1f) }, - onDecrement = { - viewModel.setNumericValue(((state.numericValue ?: 0f) - 1f).coerceAtLeast(0f)) - } - ) - } - else -> { - // Text value chips - if (state.availableValues.isNotEmpty()) { - Text( - "Select all that apply:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - state.availableValues.forEach { value -> - SelectableChip( - label = value.label, - selected = value.label in state.selectedValues, - onClick = { viewModel.toggleValue(value.label) } - ) + } else { + val config = metricConfigFor(cat, state.availableValues.map { it.label }) + val metricValue = metricValueFor(type, config, state) + val onMetricChange: (MetricValue) -> Unit = { v -> + when (v) { + is MetricValue.Choice -> viewModel.setSelectedValues(v.selected) + is MetricValue.Scale -> v.step?.let { viewModel.setNumericValue(it.toFloat()) } + is MetricValue.Continuous -> v.value?.let { viewModel.setNumericValue(it) } + is MetricValue.FreeNumber -> viewModel.setNumericFreeText(v.text) + is MetricValue.Count -> viewModel.setNumericValue(v.count.toFloat()) + is MetricValue.YesNo -> v.value?.let { + viewModel.setSelectedValues(setOf(if (it) "Yes" else "No")) + } + is MetricValue.TimeOfDay -> v.time?.let { + viewModel.setSelectedValues(setOf(it)) + } } } - // Show removed values (in historical record but no longer in catalog) - val removedValues = state.selectedValues.filter { label -> - state.availableValues.none { it.label == label } - } - if (removedValues.isNotEmpty()) { - Text( - "Previously recorded (removed from options):", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - removedValues.forEach { label -> - SelectableChip( - label = "$label (removed)", - selected = true, - onClick = { viewModel.toggleValue(label) } + if (type == CategoryType.DEFAULT) { + // Chips render bare, with the catalog empty-state and the + // "previously recorded" chips for labels no longer offered. + if (state.availableValues.isNotEmpty()) { + Text( + "Select all that apply:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + MetricInput( + type = type, + config = config, + value = metricValue, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = onMetricChange + ) + + // Show removed values (in historical record but no longer in catalog) + val removedValues = state.selectedValues.filter { label -> + state.availableValues.none { it.label == label } + } + if (removedValues.isNotEmpty()) { + Text( + "Previously recorded (removed from options):", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + removedValues.forEach { label -> + SelectableChip( + label = "$label (removed)", + selected = true, + onClick = { viewModel.toggleValue(label) } + ) + } + } + } + } else { + Text( + "No values defined for this category yet. You can add values in Settings → Tracking Categories.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + // Every other input renders in the same framed card the + // per-type sections used: category name label + control. + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = + if (type == CategoryType.INCREMENT) Alignment.CenterHorizontally + else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + cat.name, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + MetricInput( + type = type, + config = config, + value = metricValue, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = onMetricChange ) } } } - } else { - Text( - "No values defined for this category yet. You can add values in Settings → Tracking Categories.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } } } // Timed increment entries are saved immediately — no notes/save button needed - val isTimedIncrement = cat?.categoryType == "increment" && cat.trackAgainstTime - if (!isTimedIncrement) { // ── Track against time checkbox ─────────────────────────────────── diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt index 5b486b0..ff047eb 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt @@ -190,6 +190,15 @@ class LogCategoryViewModel( } } + /** + * Replaces the whole selection set. Used by the MetricInput-driven screen: + * chip multi-select passes the toggled set; the single-label yes_no and + * time types pass a one-element set ("Yes"/"No" or "HH:mm"), which is + * exactly the value-label string persisted for them. + */ + fun setSelectedValues(values: Set) = + _uiState.update { it.copy(selectedValues = values) } + fun setNumericValue(v: Float) = _uiState.update { it.copy(numericValue = v) } fun setNumericFreeText(text: String) = _uiState.update { it.copy(numericFreeText = text) } @@ -266,6 +275,8 @@ class LogCategoryViewModel( if (count <= 0) return // nothing to record; use delete to clear setOf(count.toString()) } + // default chips, yes_no ("Yes"/"No") and time ("HH:mm") all persist + // their labels straight from the selection set. else -> state.selectedValues } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt index 3680c38..5fb539c 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt @@ -59,6 +59,10 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.ui.components.MetricConfig +import com.mapgie.goflo.ui.components.MetricInput +import com.mapgie.goflo.ui.components.MetricValue +import com.mapgie.goflo.ui.util.CategoryType import com.mapgie.goflo.ui.util.decodeScaleLabels import com.mapgie.goflo.ui.components.SelectableChip import java.time.Instant @@ -414,6 +418,7 @@ fun LogPeriodScreen( onToggleValue = { viewModel.togglePinnedValue(category.id, it) }, onNumericChange = { viewModel.setPinnedNumericValue(category.id, it) }, onFreeTextChange = { viewModel.setPinnedFreeText(category.id, it) }, + onSingleValueChange = { viewModel.setPinnedSingleValue(category.id, it) }, ) } @@ -479,8 +484,44 @@ private fun PinnedCategoryInput( onToggleValue: (String) -> Unit, onNumericChange: (Float) -> Unit, onFreeTextChange: (String) -> Unit, + onSingleValueChange: (String) -> Unit = {}, ) { when (category.categoryType) { + // The two Phase 4 types delegate to the MetricInput facade; their + // readings live in the selection set as a single value label + // ("Yes"/"No", "HH:mm"), which the existing pinned-category save + // path already persists. The pre-existing branches below are + // intentionally untouched (they are fully replaced in Phase 5). + "yes_no" -> MetricInput( + type = CategoryType.YES_NO, + config = MetricConfig(name = category.name), + value = MetricValue.YesNo( + when { + "Yes" in selectedValues -> true + "No" in selectedValues -> false + else -> null + } + ), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = { v -> + (v as? MetricValue.YesNo)?.value?.let { + onSingleValueChange(if (it) "Yes" else "No") + } + }, + ) + + "time" -> MetricInput( + type = CategoryType.TIME, + config = MetricConfig(name = category.name), + value = MetricValue.TimeOfDay(selectedValues.firstOrNull()), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = { v -> + (v as? MetricValue.TimeOfDay)?.time?.let(onSingleValueChange) + }, + ) + "numeric_slider" -> { val min = category.numericMin val max = category.numericMax diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt index aef1679..9353dfd 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt @@ -344,6 +344,19 @@ class LogPeriodViewModel( state.copy(pinnedFreeTextValues = state.pinnedFreeTextValues + (categoryId to text), hasChanges = true) } + /** + * Replaces a pinned category's selection with one label. Used by the + * single-value yes_no and time input types, whose reading is stored as a + * lone value-label string ("Yes"/"No" or "HH:mm"); the existing + * selection-set save path persists it unchanged. + */ + fun setPinnedSingleValue(categoryId: Long, label: String) = _uiState.update { state -> + state.copy( + pinnedCategorySelections = state.pinnedCategorySelections + (categoryId to setOf(label)), + hasChanges = true, + ) + } + /** * Saves the day: marks [LogPeriodUiState.date] as a period day (which * starts, continues, or bridges an episode as needed), applies any diff --git a/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt b/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt index 93f61c4..10be64d 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt @@ -86,6 +86,8 @@ enum class CategoryType(val key: String, val displayName: String) { NUMERIC_SLIDER("numeric_slider", "Slider scale"), NUMERIC_FREE ("numeric_free", "Numeric (Input)"), INCREMENT ("increment", "Plus One"), + YES_NO ("yes_no", "Yes / No"), + TIME ("time", "Time"), } fun String.toCategoryType(): CategoryType = diff --git a/changelog/unreleased/logging-yesno-time-metricinput.json b/changelog/unreleased/logging-yesno-time-metricinput.json new file mode 100644 index 0000000..690356b --- /dev/null +++ b/changelog/unreleased/logging-yesno-time-metricinput.json @@ -0,0 +1,10 @@ +{ + "bump": "minor", + "added": [ + "New category input types: Yes/No and Time" + ], + "changed": [ + "Rating scales now log with discrete tap-steps instead of a drag slider (decimal and wide ranges keep the slider)", + "Unified how category inputs are rendered (no change to existing categories or their stored data)" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index baa754c..e41dcf8 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -206,7 +206,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 1 — Extended colour roles | Done | `claude/logging-redesign-phase-1-f08e0o` | 23 | Derivation deviates from the sketch in two ways: near-greyscale accents (High Contrast) shift lightness instead of hue (hue rotation of grey is a no-op), and on-colours pick near-black vs white by max contrast rather than a 0.35 luminance threshold (the threshold has a 0.30-0.35 band where white fails 3:1). Spot-check script `wcag_check_roles.py` added (all 12 families + HC + Blue & Orange, worst ratio 4.24:1). Also restored `wcag_check.py`, which was committed as one base64 line in `84624bc`. | | 2 — Group data model | Done | `claude/logging-redesign-phase-2-nuaywv` | 24 | Colour-inheritance deviation (keep existing colours, opt-in `"inherit"`) confirmed with owner. `"inherit"` is a sentinel constant, not a `CategoryColor` entry, so the Phase 1 picker does not offer it. Group methods live in `TrackingRepository` (new nullable `groupDao` ctor param) rather than a separate repository. No FK on `groupId`; `deleteGroup` unfiles members first. Migration test is a JVM test (`Migration23To24Test`) driving the real migration through sqlite-jdbc, since the project has no instrumented tests and `exportSchema = false`. | | 3 — Component library | Done | `claude/logging-redesign-phase-3` | 24 (unchanged) | 12 primitives + `MetricInput` stub in `ui/components/`, previews only — no screen consumes them yet. Deviations: `RolePicker` built standalone (visuals mirrored from the Phase 1 picker; `ManageCategoriesScreen` left untouched per this phase's "existing screens compile unchanged" rule — rewire in Phase 6/7); tonal container fills for arbitrary role colours derived via `roleContainerTint` (lerp toward surface, house `ordinalShade` pattern) since M3 `ColorScheme` has no containers for derived/fixed roles; `ToneHero` word uses onSurface on the tint (contrast-safe in every palette) with the role carried by the container; `StepScale` gained a `name` param for its single-control announcement plus per-step `customActions` so TalkBack can still operate it. `MetricValue`/`MetricConfig` value types defined now, incl. YesNo/TimeOfDay variants for Phase 4. | -| 4 — MetricInput + Yes/No + Time | Not started | | | | +| 4 — MetricInput + Yes/No + Time | Done | `claude/logging-redesign-phase-4` | 24 (unchanged) | §8 decision #3 resolved by the owner: Yes/No and Time store value-label strings ("Yes"/"No"; 24h "HH:mm") in `tracking_log_values` — no new columns, no migration. `LogCategoryScreen` renders every non-timed type through `MetricInput` (timed increment stays screen-driven, now rendering the `Timeline` primitive); rating scales ≤10 whole steps render as `StepScale` (plan §2 rule 1), wider/decimal ranges keep the parity slider incl. stepped whole-number behaviour. `TrackingCategory.isNumeric` re-defined from "not default" to an explicit numeric-type list so yes_no/time chart as label categories in Stats. `PinnedCategoryInput` gained additive yes_no/time branches delegating to `MetricInput` (existing four branches untouched; full replacement stays Phase 5). New `TimeField` primitive added to `ui/components/`. Editing a yes_no/time category still opens the default value-catalog editor (harmless; redesigned in Phase 7). | | 5 — Unified LogScreen | Not started | | | Consider sub-PRs. | | 6 — What You Track home | Not started | | | | | 7 — Create/edit + scale + alarms | Not started | | | Decide categoryType mutability. | @@ -218,5 +218,5 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r 1. **Colour default for ungrouped existing categories:** this plan keeps their current `colorToken` (no grey wipe), diverging from the handover's "neutral surfaceVariant by default". Confirm that is the desired behaviour, or accept a one-time optional "Organise your categories" nudge that offers (not forces) filing. 2. **`categoryType` mutability:** currently immutable after creation. The new edit flow implies changing type. Allowing it needs a value-migration story (e.g. scale↔count) or a documented "type is fixed once logged" constraint. Decide before Phase 7. -3. **Yes/No and Time storage encoding:** confirm storing as value-label strings ("Yes"/"No", "HH:mm") vs a dedicated column. Value-label keeps zero-migration; a column is cleaner for Stats. Decide before Phase 4. +3. **Yes/No and Time storage encoding:** ~~confirm storing as value-label strings ("Yes"/"No", "HH:mm") vs a dedicated column. Value-label keeps zero-migration; a column is cleaner for Stats. Decide before Phase 4.~~ **Resolved by the owner (2026-08-25): value-label strings** — "Yes"/"No" for yes_no, 24-hour "HH:mm" for time, stored in `tracking_log_values` exactly like existing values. No new DB columns, no migration. Implemented in Phase 4; documented at the top of `MetricInput.kt`. 4. **Theme-spec reconciliation** (`Color.kt` vs `GoFlo Theme Redesign.md`): in scope as a separate PR, or leave as-is? Not part of this logging plan. diff --git a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md index 6a5bdcb..2b96c0d 100644 --- a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md +++ b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md @@ -6,6 +6,8 @@ > - Date: 2026-08-22 > > **Staleness check for future sessions:** run `git diff d07d947 -- app/src/main/java/com/mapgie/goflo/ui/screens/log/` before trusting the line numbers below. If any log-screen file changed, re-read it. The *shape* of the description (two separate screens, `categoryType` discriminator) is durable; exact line numbers drift. +> +> **Phase 4 drift (branch `claude/logging-redesign-phase-4`):** `LogCategoryScreen` no longer contains the per-type section composables described in §2 — every non-timed input renders through the `MetricInput` facade (`ui/components/MetricInput.kt`), and the timed-increment path renders the `Timeline` primitive via a screen-level `TimedIncrementTimeline`. The screen keeps a small `when` only to map `LogCategoryUiState` onto a `MetricValue` (`metricValueFor`) and to frame card vs bare-chip layouts. Two new `categoryType` strings exist: `"yes_no"` (stores "Yes"/"No" value labels) and `"time"` (stores 24h "HH:mm" value labels); both flow through `LogCategoryUiState.selectedValues` as a single-label set, and `LogCategoryViewModel.save()`'s else-branch persists them. `PinnedCategoryInput` in `LogPeriodScreen` gained additive `"yes_no"`/`"time"` branches delegating to `MetricInput` (its four pre-existing branches and `LogPeriodViewModel.computePinnedValues` are unchanged; the new types save through the existing else/selection-set path plus a new `setPinnedSingleValue`). Line numbers below refer to the pre-Phase-4 files; the save-flow description in §4 remains accurate. ## Overview: two truly separate destinations From ad12e98cd48b863b7fdcc52fc0a75c3a7ed03656 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:25:43 +0000 Subject: [PATCH 4/8] Extract shared period-day logic into PeriodDaySync The flow slider mapping (1 Spotting / 2 Light / 4 Heavy / else Medium), the flow and symptom fan-out into the tracking system, the pinned-category value rules, and the episode day-number helper move from private members of LogPeriodViewModel into an internal PeriodDaySync object, so the unified day screen (next commit) shares the exact same code instead of a copy that could drift. LogPeriodViewModel delegates to it; behaviour is unchanged. Also widens LogCategoryScreen's metricConfigFor and TimedIncrementTimeline from private to internal for the same reuse. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- .../goflo/ui/screens/log/LogCategoryScreen.kt | 13 +- .../ui/screens/log/LogPeriodViewModel.kt | 95 +++--------- .../goflo/ui/screens/log/PeriodDaySync.kt | 138 ++++++++++++++++++ 3 files changed, 165 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt index ec8ef18..9429939 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt @@ -129,8 +129,12 @@ private fun DatePickerDialogWrapper( } } -/** Builds the [MetricConfig] the [MetricInput] facade renders from a category row. */ -private fun metricConfigFor( +/** + * Builds the [MetricConfig] the [MetricInput] facade renders from a category + * row. Internal so the unified day screen ([LogScreen]) shares the exact same + * mapping instead of a copy that could drift. + */ +internal fun metricConfigFor( category: TrackingCategory, availableValues: List, ): MetricConfig = MetricConfig( @@ -175,9 +179,12 @@ private fun metricValueFor( * timestamped log immediately, so the day renders as a running total plus a * [Timeline] of today's entries with per-entry delete. There is deliberately * no notes field or Save button on this path. + * + * Internal so the unified day screen ([LogScreen]) renders the identical + * timed-increment surface. */ @Composable -private fun TimedIncrementTimeline( +internal fun TimedIncrementTimeline( category: TrackingCategory, entries: List, onAddOne: () -> Unit, diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt index 9353dfd..6d43922 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt @@ -18,9 +18,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit /** * UI state for per-day period logging. @@ -298,12 +295,7 @@ class LogPeriodViewModel( fun setFlowSliderValue(value: Float) = _uiState.update { state -> // Map slider position to the nearest built-in label for storage. - val label = when (value.toInt()) { - 1 -> "Spotting" - 2 -> "Light" - 4 -> "Heavy" - else -> "Medium" - } + val label = PeriodDaySync.flowLabelForSliderValue(value.toInt()) state.copy(flowSliderValue = value, selectedFlowLabel = label, hasChanges = true) } @@ -429,47 +421,15 @@ class LogPeriodViewModel( * Mirrors this day's flow level into the TrackingLog system. * This ensures logged days appear in the Stats screen under the Flow category. * No-op if [trackingRepository] was not provided (e.g. in tests or legacy callers). + * Logic lives in [PeriodDaySync], shared with the unified day screen. */ - private suspend fun syncFlowToTrackingLog(state: LogPeriodUiState) { - val tr = trackingRepository ?: return - val flowCategory = tr.getSystemCategoryByKey("flow") ?: return - if (flowCategory.isArchived) return - val flowLabel = if (flowCategory.categoryType == "numeric_slider") { - val v = state.flowSliderValue ?: flowLabelToSliderValue(state.selectedFlowLabel) - v.toInt().toString() - } else { - state.selectedFlowLabel - } - tr.saveLog( - date = state.date, - categoryId = flowCategory.id, - selectedValues = setOf(flowLabel), - notes = "", - allowMultiple = false, + private suspend fun syncFlowToTrackingLog(state: LogPeriodUiState) = + PeriodDaySync.syncFlowToTrackingLog( + trackingRepository, state.date, state.selectedFlowLabel, state.flowSliderValue, ) - } - private suspend fun syncSymptomsToTrackingLog(state: LogPeriodUiState) { - val tr = trackingRepository ?: return - val symptomsCategory = tr.getSystemCategoryByKey("symptoms") ?: return - if (symptomsCategory.isArchived) return - if (state.symptoms.isEmpty()) { - val existing = tr.getExistingLog(state.date, symptomsCategory.id) ?: return - tr.deleteLog(existing.log) - } else { - val loggedAt = if (symptomsCategory.trackAgainstTime) { - LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) - } else "" - tr.saveLog( - date = state.date, - categoryId = symptomsCategory.id, - selectedValues = state.symptoms, - notes = "", - allowMultiple = false, - loggedAt = loggedAt, - ) - } - } + private suspend fun syncSymptomsToTrackingLog(state: LogPeriodUiState) = + PeriodDaySync.syncSymptomsToTrackingLog(trackingRepository, state.date, state.symptoms) /** Saves each pinned category's current selection as a tracking log for the day being logged. */ private suspend fun syncPinnedCategoryLogs(state: LogPeriodUiState) { @@ -488,27 +448,12 @@ class LogPeriodViewModel( } private fun computePinnedValues(cat: TrackingCategory, state: LogPeriodUiState): Set? = - when (cat.categoryType) { - "numeric_slider" -> { - // Fall back to numericMin so the slider's displayed position is always saved. - val v = state.pinnedNumericValues[cat.id] ?: cat.numericMin - setOf(if (cat.allowDecimals) "%.1f".format(v) else v.toInt().toString()) - } - "numeric_free" -> { - val text = (state.pinnedFreeTextValues[cat.id] ?: "").trim() - if (text.isEmpty()) null else setOf(text) - } - "increment" -> { - // Always save, including 0 — a zero count is meaningful data for a - // category the user chose to track alongside periods. - val count = state.pinnedNumericValues[cat.id]?.toInt() ?: 0 - setOf(count.toString()) - } - else -> { - val selected = state.pinnedCategorySelections[cat.id] ?: emptySet() - if (selected.isEmpty()) null else selected - } - } + PeriodDaySync.computePinnedValues( + cat = cat, + numericValue = state.pinnedNumericValues[cat.id], + freeText = state.pinnedFreeTextValues[cat.id] ?: "", + selections = state.pinnedCategorySelections[cat.id] ?: emptySet(), + ) fun disablePeriodTracking() { viewModelScope.launch { preferencesStore?.setPeriodTrackingEnabled(false) } @@ -552,17 +497,11 @@ class LogPeriodViewModel( } companion object { - private fun flowLabelToSliderValue(label: String): Float = when (label) { - "Spotting" -> 1f - "Light" -> 2f - "Heavy" -> 4f - else -> 3f // "Medium" and any custom label default to the middle - } + private fun flowLabelToSliderValue(label: String): Float = + PeriodDaySync.flowLabelToSliderValue(label) /** 1-based day number of [date] within an episode starting at [start], or null when before it. */ - private fun dayNumber(start: LocalDate, date: LocalDate): Int? { - val n = ChronoUnit.DAYS.between(start, date).toInt() + 1 - return if (n >= 1) n else null - } + private fun dayNumber(start: LocalDate, date: LocalDate): Int? = + PeriodDaySync.dayNumber(start, date) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt new file mode 100644 index 0000000..f016f66 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt @@ -0,0 +1,138 @@ +package com.mapgie.goflo.ui.screens.log + +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.repository.TrackingRepository +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +/** + * Period-day logic shared between [LogPeriodViewModel] (the standalone period + * screen) and [LogViewModel] (the unified day screen). + * + * Extracted rather than duplicated so the flow slider mapping and the save + * fan-out into the tracking system (which make period data appear under + * Flow/Symptoms/pinned categories in Stats) cannot drift between the two + * surfaces. Behaviour is byte-for-byte the pre-extraction LogPeriodViewModel + * logic. + */ +internal object PeriodDaySync { + + /** + * Maps a flow slider position to the built-in label stored for the day: + * 1 = Spotting, 2 = Light, 4 = Heavy, anything else = Medium. + */ + fun flowLabelForSliderValue(value: Int): String = when (value) { + 1 -> "Spotting" + 2 -> "Light" + 4 -> "Heavy" + else -> "Medium" + } + + /** Inverse mapping; "Medium" and any custom label default to the middle. */ + fun flowLabelToSliderValue(label: String): Float = when (label) { + "Spotting" -> 1f + "Light" -> 2f + "Heavy" -> 4f + else -> 3f + } + + /** 1-based day number of [date] within an episode starting at [start], or null when before it. */ + fun dayNumber(start: LocalDate, date: LocalDate): Int? { + val n = ChronoUnit.DAYS.between(start, date).toInt() + 1 + return if (n >= 1) n else null + } + + /** + * Mirrors the day's flow level into the TrackingLog system so logged days + * appear in the Stats screen under the Flow category. + * No-op if [trackingRepository] is null (e.g. in tests or legacy callers). + */ + suspend fun syncFlowToTrackingLog( + trackingRepository: TrackingRepository?, + date: LocalDate, + selectedFlowLabel: String, + flowSliderValue: Float?, + ) { + val tr = trackingRepository ?: return + val flowCategory = tr.getSystemCategoryByKey("flow") ?: return + if (flowCategory.isArchived) return + val flowLabel = if (flowCategory.categoryType == "numeric_slider") { + val v = flowSliderValue ?: flowLabelToSliderValue(selectedFlowLabel) + v.toInt().toString() + } else { + selectedFlowLabel + } + tr.saveLog( + date = date, + categoryId = flowCategory.id, + selectedValues = setOf(flowLabel), + notes = "", + allowMultiple = false, + ) + } + + /** + * Mirrors the day's symptom set into the TrackingLog system. An empty set + * deletes the day's existing symptoms log (deselecting everything clears + * the record rather than leaving a stale one). + */ + suspend fun syncSymptomsToTrackingLog( + trackingRepository: TrackingRepository?, + date: LocalDate, + symptoms: Set, + ) { + val tr = trackingRepository ?: return + val symptomsCategory = tr.getSystemCategoryByKey("symptoms") ?: return + if (symptomsCategory.isArchived) return + if (symptoms.isEmpty()) { + val existing = tr.getExistingLog(date, symptomsCategory.id) ?: return + tr.deleteLog(existing.log) + } else { + val loggedAt = if (symptomsCategory.trackAgainstTime) { + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + } else "" + tr.saveLog( + date = date, + categoryId = symptomsCategory.id, + selectedValues = symptoms, + notes = "", + allowMultiple = false, + loggedAt = loggedAt, + ) + } + } + + /** + * The value set a pinned ("Log with period") category saves for the day, + * or null when there is nothing to record: + * - slider: falls back to numericMin so the displayed position always saves + * - free numeric: skipped while empty + * - count: always saves, including 0 (a zero count is meaningful data for a + * category the user chose to track alongside periods) + * - everything else: the selection set, skipped while empty + */ + fun computePinnedValues( + cat: TrackingCategory, + numericValue: Float?, + freeText: String, + selections: Set, + ): Set? = when (cat.categoryType) { + "numeric_slider" -> { + val v = numericValue ?: cat.numericMin + setOf(if (cat.allowDecimals) "%.1f".format(v) else v.toInt().toString()) + } + "numeric_free" -> { + val text = freeText.trim() + if (text.isEmpty()) null else setOf(text) + } + "increment" -> { + val count = numericValue?.toInt() ?: 0 + setOf(count.toString()) + } + else -> { + if (selections.isEmpty()) null else selections + } + } +} From ed027adcc5c3fdca4a6b396f7ed25b6b888d8344 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:26:02 +0000 Subject: [PATCH 5/8] Add unified LogScreen(date): one screen logs a day, period is a state New LogScreen + LogViewModel compose the Phase 3/4 primitives into one per-day surface behind the additive route log_day?date={date}: - Off-period: the first tracked category leads as a ToneHero, flow is not rendered, and the footer is a quiet Period-started-today row (which flips the screen into its on-period arrangement before saving). - On-period: the Flow group (StepScale, stepped slider, or chips per the category's mode, with the 1 Spotting / 2 Light / 4 Heavy / else Medium mapping) slots in at the top with the period dates card, pinned showInLogPeriod categories render in the flow context, and the footer is a filled status row with End/Undo. - Between the two states everything is identical: symptoms chips with the inline Add dialog, tracked metrics organised by group (two or more members render as one ListCard of rows with the active row's input beneath; a group of one renders as its own open section), per-entry notes with the 500-char cap, previously-recorded chips, track-against-time, per-entry delete, and timed-increment timelines with per-tap saves. - The title and every metric header open a switch sheet organised by group and tinted by role: from the title it switches day or jumps to a category; from a metric header it re-files the entered value under another category without losing it. - Saving reuses the period screen's exact sequence via PeriodDaySync (episode continuation and boundary edits, episode meta, flow/symptom/pinned fan-out, widget and prediction-reminder refresh); off-period saves write only categories the user touched, using the category screen's per-type rules. Remove-day, delete-entire-period, disable-period-logging, and the unsaved-changes guard are all ported. The LogPeriod and LogCategory routes stay registered and every existing entry point still uses them; the only new entry is an opt-in preview row in the calendar day sheet. Removal is Phase 8. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- .../java/com/mapgie/goflo/MainActivity.kt | 29 + .../mapgie/goflo/ui/components/DayLogSheet.kt | 14 + .../com/mapgie/goflo/ui/navigation/Screen.kt | 14 + .../goflo/ui/screens/home/HomeScreen.kt | 4 + .../mapgie/goflo/ui/screens/log/LogScreen.kt | 1410 +++++++++++++++++ .../goflo/ui/screens/log/LogViewModel.kt | 752 +++++++++ 6 files changed, 2223 insertions(+) create mode 100644 app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index 94bebbf..e943b50 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -590,6 +590,35 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa onNavigateBack = { navController.popBackStack() } ) } + + // ── Unified day logging (logging redesign Phase 5) ─────────────────── + // Additive route: the LogPeriod and LogCategory destinations above + // stay registered and reachable until parity sign-off (Phase 8). + + composable( + route = Screen.LogDay.route, + arguments = listOf( + navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null } + ) + ) { backStack -> + val dateStr = backStack.arguments?.getString("date") + val date = dateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } + ?: java.time.LocalDate.now() + val vm: com.mapgie.goflo.ui.screens.log.LogViewModel = viewModel( + key = "log_day_$dateStr", + factory = com.mapgie.goflo.ui.screens.log.LogViewModel.Factory( + repository = app.repository, + trackingRepository = app.trackingRepository, + date = date, + application = app, + preferencesStore = app.preferencesStore, + ) + ) + com.mapgie.goflo.ui.screens.log.LogScreen( + viewModel = vm, + onBack = { navController.popBackStack() } + ) + } } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt b/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt index 7e28029..8c31a50 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt @@ -68,6 +68,11 @@ fun DayLogSheet( onEditPeriod: (Long) -> Unit, onEditTrackingLog: (categoryId: Long, logId: Long) -> Unit, onLogMore: () -> Unit, + /** + * Optional entry to the unified day screen (logging redesign Phase 5). + * Null hides the row; the classic per-screen actions above are unaffected. + */ + onOpenDayLog: (() -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState() @@ -221,6 +226,15 @@ fun DayLogSheet( Text("Log more for this day…") } + if (onOpenDayLog != null) { + TextButton( + onClick = { onDismiss(); onOpenDayLog() }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Try the new day log (preview)") + } + } + Spacer(Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt b/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt index fde49d8..3b2a84f 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt @@ -66,4 +66,18 @@ sealed class Screen(val route: String) { fun editEntry(categoryId: Long, logId: Long) = "log_category/$categoryId?logId=$logId" } + + // ── Unified day logging (logging redesign Phase 5) ───────────────────────── + + /** + * Route for the unified day screen, where a running period is a state of + * the day rather than a separate destination. + * + * Additive: [LogPeriod] and [LogCategory] stay registered and reachable + * until the parity sign-off (removal is Phase 8 of the logging redesign). + * - [date] — ISO 8601 date string; omit to default to today + */ + data object LogDay : Screen("log_day?date={date}") { + fun forDate(date: LocalDate) = "log_day?date=$date" + } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt index 1600cb6..e02c25d 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt @@ -170,6 +170,10 @@ fun HomeScreen( viewModel.clearSelectedDay() openLogMenuFor(data.date) }, + onOpenDayLog = { + viewModel.clearSelectedDay() + onNavigate(Screen.LogDay.forDate(data.date)) + }, ) } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt new file mode 100644 index 0000000..4d00b17 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt @@ -0,0 +1,1410 @@ +package com.mapgie.goflo.ui.screens.log + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.ui.components.ChipRow +import com.mapgie.goflo.ui.components.HairlineDivider +import com.mapgie.goflo.ui.components.ListCard +import com.mapgie.goflo.ui.components.ListRow +import com.mapgie.goflo.ui.components.MetricConfig +import com.mapgie.goflo.ui.components.MetricInput +import com.mapgie.goflo.ui.components.MetricValue +import com.mapgie.goflo.ui.components.PrimarySaveBar +import com.mapgie.goflo.ui.components.SectionHeader +import com.mapgie.goflo.ui.components.SelectableChip +import com.mapgie.goflo.ui.components.ToneHero +import com.mapgie.goflo.ui.components.roleContainerTint +import com.mapgie.goflo.ui.components.usesStepScale +import com.mapgie.goflo.ui.util.CategoryType +import com.mapgie.goflo.ui.util.effectiveColorToken +import com.mapgie.goflo.ui.util.toCategoryColor +import com.mapgie.goflo.ui.util.toCategoryOnColor +import com.mapgie.goflo.ui.util.toCategoryType +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val displayFormat = DateTimeFormatter.ofPattern("MMM d, yyyy") + +// Sentinels for the switch sheet: closed / opened from the title (jump) / +// opened from a metric header (re-file, value = source category id). +private const val SHEET_CLOSED = 0L +private const val SHEET_JUMP = -1L + +/** + * The unified day screen: one screen logs a day, and a running period is a + * state of that day rather than a separate destination. + * + * Off-period, the first tracked category leads as a tonal hero and the footer + * is a quiet "Period started today" row. On-period, the Flow group slots in at + * the top, the lead category compresses into the tracked list, and the footer + * becomes a filled status row with an End action. Everything between renders + * identically in both states. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun LogScreen( + viewModel: LogViewModel, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.saved, state.deleted) { + if (state.saved || state.deleted) onBack() + } + + var showDayPicker by rememberSaveable { mutableStateOf(false) } + var showStartPicker by rememberSaveable { mutableStateOf(false) } + var showEndPicker by rememberSaveable { mutableStateOf(false) } + var showDeleteConfirm by rememberSaveable { mutableStateOf(false) } + var showRemoveDayConfirm by rememberSaveable { mutableStateOf(false) } + var showAddSymptomDialog by rememberSaveable { mutableStateOf(false) } + var showUnsavedChangesDialog by rememberSaveable { mutableStateOf(false) } + var showOverflowMenu by rememberSaveable { mutableStateOf(false) } + /** SHEET_CLOSED, SHEET_JUMP, or the category id a re-file was opened from. */ + var switchSheetMode by rememberSaveable { mutableStateOf(SHEET_CLOSED) } + /** Category id awaiting delete-entry confirmation, or 0 when none. */ + var pendingDeleteEntryId by rememberSaveable { mutableStateOf(0L) } + /** Day picked while unsaved changes exist, awaiting discard confirmation. */ + var pendingDaySwitch by rememberSaveable { mutableStateOf(null) } + + val handleBack: () -> Unit = { + if (state.hasChanges) showUnsavedChangesDialog = true else onBack() + } + BackHandler(enabled = state.hasChanges) { showUnsavedChangesDialog = true } + + // ── Dialogs ─────────────────────────────────────────────────────────────── + + if (showDayPicker && !state.isLoading) { + DatePickerDialogWrapper( + initial = state.date, + onConfirm = { picked -> + showDayPicker = false + if (picked != state.date) { + if (state.hasChanges) pendingDaySwitch = picked.toString() + else viewModel.setDate(picked) + } + }, + onDismiss = { showDayPicker = false }, + ) + } + + if (showStartPicker && !state.isLoading) { + DatePickerDialogWrapper( + initial = state.episodeStart ?: state.date, + onConfirm = { viewModel.setStartDate(it); showStartPicker = false }, + onDismiss = { showStartPicker = false }, + ) + } + + if (showEndPicker && !state.isLoading) { + DatePickerDialogWrapper( + initial = state.endDate ?: state.date, + minDate = state.episodeStart ?: state.date, + onConfirm = { viewModel.setEndDate(it); showEndPicker = false }, + onDismiss = { showEndPicker = false }, + ) + } + + pendingDaySwitch?.let { pendingIso -> + AlertDialog( + onDismissRequest = { pendingDaySwitch = null }, + title = { Text("Switch day?") }, + text = { Text("This day has unsaved changes. Switching to another day discards them.") }, + confirmButton = { + TextButton( + onClick = { + val target = runCatching { LocalDate.parse(pendingIso) }.getOrNull() + pendingDaySwitch = null + target?.let { viewModel.setDate(it) } + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Discard and switch") } + }, + dismissButton = { + TextButton(onClick = { pendingDaySwitch = null }) { Text("Cancel") } + }, + ) + } + + if (showDeleteConfirm && !state.isLoading) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete period?") }, + text = { Text("This will permanently remove this entire period, including every logged day in it.") }, + confirmButton = { + TextButton( + onClick = { showDeleteConfirm = false; viewModel.deleteEpisode() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showRemoveDayConfirm && !state.isLoading) { + AlertDialog( + onDismissRequest = { showRemoveDayConfirm = false }, + title = { Text("Remove this day?") }, + text = { Text( + "${state.date.format(displayFormat)} will no longer count as a period day. " + + "Anything else logged for this day is kept." + ) }, + confirmButton = { + TextButton( + onClick = { showRemoveDayConfirm = false; viewModel.removeDay() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Remove day") } + }, + dismissButton = { TextButton(onClick = { showRemoveDayConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showUnsavedChangesDialog) { + AlertDialog( + onDismissRequest = { showUnsavedChangesDialog = false }, + title = { Text("Unsaved changes") }, + text = { Text("Do you want to save this entry before going back?") }, + confirmButton = { + Button(onClick = { showUnsavedChangesDialog = false; viewModel.save() }) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = { showUnsavedChangesDialog = false; onBack() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Discard") } + }, + ) + } + + if (showAddSymptomDialog) { + AddSymptomDialog( + existingLabels = state.symptomOptions.map { it.label }, + selectedLabels = state.symptoms, + onAdd = { name -> + viewModel.addNewSymptomToLibrary(name) + showAddSymptomDialog = false + }, + onDismiss = { showAddSymptomDialog = false }, + ) + } + + if (pendingDeleteEntryId != 0L) { + val cat = state.categories.firstOrNull { it.id == pendingDeleteEntryId } + AlertDialog( + onDismissRequest = { pendingDeleteEntryId = 0L }, + title = { Text("Delete this entry?") }, + text = { Text( + "The ${cat?.name ?: "category"} entry for " + + "${state.date.format(displayFormat)} will be permanently removed." + ) }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteEntry(pendingDeleteEntryId) + pendingDeleteEntryId = 0L + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { pendingDeleteEntryId = 0L }) { Text("Cancel") } }, + ) + } + + if (switchSheetMode != SHEET_CLOSED) { + DaySwitchSheet( + refileSourceId = switchSheetMode.takeIf { it > 0L }, + state = state, + onPickDay = { + switchSheetMode = SHEET_CLOSED + showDayPicker = true + }, + onPickCategory = { categoryId -> + val mode = switchSheetMode + switchSheetMode = SHEET_CLOSED + if (mode > 0L) viewModel.refileEntry(mode, categoryId) + else viewModel.setActiveCategory(categoryId) + }, + onDismiss = { switchSheetMode = SHEET_CLOSED }, + ) + } + + // ── Scaffold ────────────────────────────────────────────────────────────── + + Scaffold( + topBar = { + LogDayTopBar( + state = state, + onBack = handleBack, + onTitleClick = { switchSheetMode = SHEET_JUMP }, + showOverflowMenu = showOverflowMenu, + onOverflowChange = { showOverflowMenu = it }, + onDisablePeriodTracking = { + viewModel.disablePeriodTracking() + onBack() + }, + ) + } + ) { padding -> + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + return@Scaffold + } + + Box(Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 104.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DaySection(state, onPickDay = { showDayPicker = true }) + + if (state.periodActive) { + PeriodDatesSection( + state = state, + onPickStart = { showStartPicker = true }, + onPickEnd = { showEndPicker = true }, + onClearEnd = { viewModel.setEndDate(null) }, + ) + FlowSection(state, viewModel) + } + + // Pinned ("Log with period") categories render in the flow + // context while the day is on-period. + val pinned = if (state.periodActive) { + state.categories.filter { it.showInLogPeriod } + } else emptyList() + pinned.forEach { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + onSwitchCategory = { switchSheetMode = cat.id }, + onDeleteEntry = { pendingDeleteEntryId = cat.id }, + ) + } + + // Off-period the first tracked category leads as the hero. + val lead = if (!state.periodActive) { + state.categories.firstOrNull() + } else null + lead?.let { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + hero = true, + onSwitchCategory = { switchSheetMode = cat.id }, + onDeleteEntry = { pendingDeleteEntryId = cat.id }, + ) + } + + SymptomsSection(state, viewModel, onAddSymptom = { showAddSymptomDialog = true }) + + TrackingSections( + state = state, + viewModel = viewModel, + excludeIds = (pinned.map { it.id } + listOfNotNull(lead?.id)).toSet(), + onSwitchCategory = { switchSheetMode = it }, + onDeleteEntry = { pendingDeleteEntryId = it }, + ) + + if (state.periodActive) { + SectionHeader(label = "Notes", value = "Optional") + OutlinedTextField( + value = state.periodNotes, + onValueChange = { if (it.length <= 500) viewModel.setPeriodNotes(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("How are you feeling? Any other details…") }, + minLines = 3, + maxLines = 6, + supportingText = { Text("${state.periodNotes.length}/500") }, + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + ), + ) + } + + PeriodFooter( + state = state, + onStartPeriod = viewModel::startPeriodToday, + onUndoStart = viewModel::undoStartPeriod, + onEndPeriod = viewModel::endPeriodOnThisDay, + onUndoEnd = viewModel::undoEndPeriod, + ) + + if (state.isPeriodDay || (state.episodeId != null && state.dayInEpisode)) { + OutlinedButton( + onClick = { showRemoveDayConfirm = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Remove this day from period") } + if (state.episodeId != null) { + OutlinedButton( + onClick = { showDeleteConfirm = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { Text("Delete Entire Period") } + } + } + + state.error?.let { + Text( + text = "Error: $it", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive }, + ) + } + } + + PrimarySaveBar( + label = if (state.date == LocalDate.now()) "Save today" else "Save day", + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onClick = viewModel::save, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding(), + ) + } + } +} + +// ── Top bar ─────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogDayTopBar( + state: LogUiState, + onBack: () -> Unit, + onTitleClick: () -> Unit, + showOverflowMenu: Boolean, + onOverflowChange: (Boolean) -> Unit, + onDisablePeriodTracking: () -> Unit, +) { + val subtitle = if (state.isLoading) null else buildString { + append(state.date.format(displayFormat)) + val dayNo = state.episodeDayNumber + if (state.periodActive && dayNo != null) append(" · period day $dayNo") + } + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .semantics { this.role = Role.Button } + .clickable(onClick = onTitleClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + Text(if (state.date == LocalDate.now()) "Log today" else "Log day") + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + } + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "Switch day or category", + modifier = Modifier.padding(start = 4.dp).size(20.dp), + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (state.periodTrackingEnabled) { + IconButton(onClick = { onOverflowChange(true) }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { onOverflowChange(false) }, + ) { + DropdownMenuItem( + text = { Text("Disable period logging") }, + onClick = { + onOverflowChange(false) + onDisablePeriodTracking() + }, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) +} + +// ── Day + period dates ──────────────────────────────────────────────────────── + +@Composable +private fun DaySection(state: LogUiState, onPickDay: () -> Unit) { + SectionHeader(label = "Day") + ListCard { + ListRow( + key = "Date", + value = state.date.format(displayFormat), + valueEmphasis = true, + onClick = onPickDay, + ) + } + // Continuation context changes as the user picks days and toggles the + // period state, so announce it politely to screen readers. + if (state.periodActive) { + val text = when { + state.startPeriodToday && state.continuesEpisodeStart != null -> { + val dayNo = state.episodeDayNumber + if (dayNo != null && dayNo > 1) { + "Day $dayNo of the period started ${state.continuesEpisodeStart.format(displayFormat)}" + } else { + "Continues the period started ${state.continuesEpisodeStart.format(displayFormat)}" + } + } + state.startPeriodToday -> "Starts a new period" + state.episodeDayNumber != null -> "Day ${state.episodeDayNumber} of this period" + else -> null + } + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } +} + +@Composable +private fun PeriodDatesSection( + state: LogUiState, + onPickStart: () -> Unit, + onPickEnd: () -> Unit, + onClearEnd: () -> Unit, +) { + if (state.episodeId != null) { + SectionHeader(label = "Period dates") + ListCard { + ListRow( + key = "Started", + value = (state.episodeStart ?: state.date).format(displayFormat), + valueEmphasis = true, + onClick = onPickStart, + ) + HairlineDivider() + ListRow( + key = "Ended", + value = state.endDate?.format(displayFormat) ?: "Still ongoing", + valueEmphasis = true, + valueColor = if (state.endDate == null) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + onClick = onPickEnd, + ) + } + if (state.endDate != null) { + TextButton(onClick = onClearEnd) { Text("Clear end date (leave open)") } + } + } else { + SectionHeader(label = "End date", value = "Optional") + ListCard { + ListRow( + key = "Ends", + value = state.endDate?.let { "Until ${it.format(displayFormat)}" } ?: "No end date", + valueEmphasis = state.endDate != null, + onClick = onPickEnd, + ) + } + if (state.endDate != null) { + TextButton(onClick = onClearEnd) { Text("Clear end date") } + } + Text( + "Without an end date, the period ends on its own after " + + "${state.toleranceDays + 1} days with no period day logged. " + + "Log each day to record how it changes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ── Flow ────────────────────────────────────────────────────────────────────── + +@Composable +private fun FlowSection(state: LogUiState, viewModel: LogViewModel) { + val flowCat = state.flowCategory ?: return + if (flowCat.isArchived) return + val token = flowCat.effectiveColorToken(state.groups) + val role = token.toCategoryColor() + val onRole = token.toCategoryOnColor() + + if (flowCat.categoryType == "numeric_slider") { + val config = metricConfigFor(flowCat, emptyList()) + val current = state.flowSliderValue?.toInt() + val word = current?.let { config.stepLabels[it] } ?: state.selectedFlowLabel + SectionHeader(label = state.flowCategoryName, value = word, valueColor = role) + MetricInput( + type = CategoryType.NUMERIC_SLIDER, + config = config, + value = if (config.usesStepScale()) MetricValue.Scale(current) + else MetricValue.Continuous(state.flowSliderValue), + role = role, + onRole = onRole, + onChange = { v -> + when (v) { + is MetricValue.Scale -> v.step?.let { viewModel.setFlowSliderValue(it.toFloat()) } + is MetricValue.Continuous -> v.value?.let { viewModel.setFlowSliderValue(it) } + else -> {} + } + }, + ) + } else { + SectionHeader( + label = state.flowCategoryName, + value = state.selectedFlowLabel, + valueColor = role, + ) + if (state.flowOptions.isNotEmpty()) { + ChipRow( + options = state.flowOptions.map { it.label }, + selected = setOf(state.selectedFlowLabel), + role = role, + onToggle = { viewModel.setFlowLevel(it) }, + ) + } else { + Text( + "No flow levels configured. Add levels in Settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Symptoms ────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SymptomsSection( + state: LogUiState, + viewModel: LogViewModel, + onAddSymptom: () -> Unit, +) { + val symptomsCat = state.symptomsCategory ?: return + if (symptomsCat.isArchived) return + val token = symptomsCat.effectiveColorToken(state.groups) + val role = token.toCategoryColor() + + SectionHeader( + label = state.symptomsCategoryName, + value = state.symptoms.size.takeIf { it > 0 }?.let { "$it today" }, + valueColor = role, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.symptomOptions.forEach { option -> + SelectableChip( + label = option.label, + selected = option.label in state.symptoms, + onClick = { viewModel.toggleSymptom(option.label) }, + ) + } + AssistChip( + onClick = onAddSymptom, + label = { Text("Add") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add symptom", + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + border = AssistChipDefaults.assistChipBorder( + enabled = true, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + ), + ) + } +} + +// ── Tracked metric sections ─────────────────────────────────────────────────── + +/** + * Renders every remaining tracked category, organised by group: a group of + * two or more categories renders as one card of rows (tap a row to open its + * input below the card); a group of one, and lone ungrouped categories, + * render as their own always-open section. + */ +@Composable +private fun TrackingSections( + state: LogUiState, + viewModel: LogViewModel, + excludeIds: Set, + onSwitchCategory: (Long) -> Unit, + onDeleteEntry: (Long) -> Unit, +) { + val shown = state.categories.filter { it.id !in excludeIds } + if (shown.isEmpty()) return + + val groupIds = state.groups.map { it.id }.toSet() + val grouped = shown + .filter { cat -> cat.groupId.let { it != null && it in groupIds } } + .groupBy { it.groupId } + val ungrouped = shown.filter { cat -> cat.groupId.let { it == null || it !in groupIds } } + + state.groups.forEach { group -> + val members = grouped[group.id] ?: return@forEach + if (members.size >= 2) { + GroupCardSection( + title = group.name, + members = members, + state = state, + viewModel = viewModel, + onSwitchCategory = onSwitchCategory, + onDeleteEntry = onDeleteEntry, + ) + } else { + members.forEach { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + onSwitchCategory = { onSwitchCategory(cat.id) }, + onDeleteEntry = { onDeleteEntry(cat.id) }, + ) + } + } + } + + if (ungrouped.size >= 2) { + GroupCardSection( + title = "Tracking", + members = ungrouped, + state = state, + viewModel = viewModel, + onSwitchCategory = onSwitchCategory, + onDeleteEntry = onDeleteEntry, + ) + } else { + ungrouped.forEach { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + onSwitchCategory = { onSwitchCategory(cat.id) }, + onDeleteEntry = { onDeleteEntry(cat.id) }, + ) + } + } +} + +/** One card of rows for a multi-category group, plus the active row's input. */ +@Composable +private fun GroupCardSection( + title: String, + members: List, + state: LogUiState, + viewModel: LogViewModel, + onSwitchCategory: (Long) -> Unit, + onDeleteEntry: (Long) -> Unit, +) { + SectionHeader(label = title, value = "${members.size} metrics") + ListCard { + members.forEachIndexed { index, cat -> + val entry = state.entries[cat.id] ?: DayMetricEntry() + val config = metricConfigFor(cat, state.categoryValues[cat.id] ?: emptyList()) + val summary = entrySummary(cat, entry, config) + val token = cat.effectiveColorToken(state.groups) + ListRow( + key = cat.name, + value = summary ?: "Add", + valueEmphasis = summary != null, + valueColor = if (summary != null) token.toCategoryColor() + else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { + viewModel.setActiveCategory( + if (state.activeCategoryId == cat.id) null else cat.id + ) + }, + ) + if (index < members.lastIndex) HairlineDivider() + } + } + members.firstOrNull { it.id == state.activeCategoryId }?.let { active -> + CategoryMetricSection( + category = active, + state = state, + viewModel = viewModel, + onSwitchCategory = { onSwitchCategory(active.id) }, + onDeleteEntry = { onDeleteEntry(active.id) }, + ) + } +} + +/** + * One tracked category's full input surface: header (the category name is a + * button that opens the re-file sheet), the [MetricInput] for its type (or the + * timed-increment timeline), "previously recorded" chips for stored labels no + * longer in the catalog, the track-against-time checkbox, per-entry notes, and + * a delete action when an entry already exists. As the off-period [hero], the + * input nests inside a [ToneHero] that shows the current reading as words. + */ +@Composable +private fun CategoryMetricSection( + category: TrackingCategory, + state: LogUiState, + viewModel: LogViewModel, + onSwitchCategory: () -> Unit, + onDeleteEntry: () -> Unit, + hero: Boolean = false, +) { + val entry = state.entries[category.id] ?: DayMetricEntry() + val availableValues = state.categoryValues[category.id] ?: emptyList() + val config = metricConfigFor(category, availableValues) + val token = category.effectiveColorToken(state.groups) + val role = token.toCategoryColor() + val onRole = token.toCategoryOnColor() + val summary = entrySummary(category, entry, config) + + if (hero) { + MetricHeaderButton( + name = category.name, + value = null, + valueColor = role, + onClick = onSwitchCategory, + ) + ToneHero( + word = summary ?: "Not logged yet", + role = role, + ) { + MetricSectionBody(category, entry, availableValues, config, role, onRole, viewModel, onDeleteEntry) + } + } else { + MetricHeaderButton( + name = category.name, + value = summary, + valueColor = role, + onClick = onSwitchCategory, + ) + MetricSectionBody(category, entry, availableValues, config, role, onRole, viewModel, onDeleteEntry) + } +} + +/** + * The category-name header row: the name is a button opening the re-file + * sheet ("logged the wrong thing?"), with the current value right-aligned. + */ +@Composable +private fun MetricHeaderButton( + name: String, + value: String?, + valueColor: Color, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .semantics { this.role = Role.Button } + .clickable(onClick = onClick) + .heightIn(min = 44.dp), + ) { + Text( + text = name.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.11.em, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "File under another category", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 2.dp).size(16.dp), + ) + } + if (value != null) { + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = valueColor, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MetricSectionBody( + category: TrackingCategory, + entry: DayMetricEntry, + availableValues: List, + config: MetricConfig, + role: Color, + onRole: Color, + viewModel: LogViewModel, + onDeleteEntry: () -> Unit, +) { + val type = category.categoryType.toCategoryType() + val isTimedIncrement = type == CategoryType.INCREMENT && category.trackAgainstTime + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (isTimedIncrement) { + // Per-tap immediate saves with a timeline, exactly as the category + // screen renders it. + TimedIncrementTimeline( + category = category, + entries = entry.timedEntries, + onAddOne = { viewModel.addTimedIncrement(category.id) }, + onDeleteEntry = { viewModel.deleteTimedEntry(category.id, it) }, + ) + return@Column + } + + if (type == CategoryType.DEFAULT && availableValues.isEmpty()) { + Text( + "No values defined for this category yet. You can add values in " + + "Settings → Tracking Categories.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + MetricInput( + type = type, + config = config, + value = metricValueForEntry(type, config, entry), + role = role, + onRole = onRole, + onChange = { v -> + when (v) { + is MetricValue.Choice -> viewModel.setEntrySelection(category.id, v.selected) + is MetricValue.Scale -> + v.step?.let { viewModel.setEntryNumeric(category.id, it.toFloat()) } + is MetricValue.Continuous -> + v.value?.let { viewModel.setEntryNumeric(category.id, it) } + is MetricValue.FreeNumber -> viewModel.setEntryFreeText(category.id, v.text) + is MetricValue.Count -> + viewModel.setEntryNumeric(category.id, v.count.toFloat()) + is MetricValue.YesNo -> v.value?.let { + viewModel.setEntrySelection(category.id, setOf(if (it) "Yes" else "No")) + } + is MetricValue.TimeOfDay -> v.time?.let { + viewModel.setEntrySelection(category.id, setOf(it)) + } + } + }, + ) + } + + // Stored labels no longer offered by the catalog stay visible and + // deselectable, exactly as on the category screen. + if (type == CategoryType.DEFAULT) { + val removedValues = entry.selectedValues.filter { it !in availableValues } + if (removedValues.isNotEmpty()) { + Text( + "Previously recorded (removed from options):", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + removedValues.forEach { label -> + SelectableChip( + label = "$label (removed)", + selected = true, + onClick = { viewModel.toggleEntryValue(category.id, label) }, + ) + } + } + } + } + + if (category.trackAgainstTime) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = entry.trackTime, + onCheckedChange = { viewModel.setEntryTrackTime(category.id, it) }, + ) + Text("Track against time", style = MaterialTheme.typography.bodyMedium) + } + } + + // Per-entry notes: shown when the entry already carries notes, or on + // demand, so a dozen categories never means a dozen empty text boxes. + var noteOpen by rememberSaveable(category.id) { mutableStateOf(false) } + if (entry.notes.isNotEmpty() || noteOpen) { + OutlinedTextField( + value = entry.notes, + onValueChange = { if (it.length <= 500) viewModel.setEntryNotes(category.id, it) }, + label = { Text("Notes (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + supportingText = { + if (entry.notes.isNotEmpty()) Text("${entry.notes.length}/500") + }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (entry.notes.isEmpty() && !noteOpen) { + TextButton(onClick = { noteOpen = true }) { Text("Add note") } + } + if (entry.existingLog != null) { + TextButton( + onClick = onDeleteEntry, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { Text("Delete entry") } + } + } + } +} + +// ── Period footer ───────────────────────────────────────────────────────────── + +/** + * Off-period: a quiet hairline row that starts (or continues) a period today. + * On-period: a filled status row naming the period state, with End/Undo. + */ +@Composable +private fun PeriodFooter( + state: LogUiState, + onStartPeriod: () -> Unit, + onUndoStart: () -> Unit, + onEndPeriod: () -> Unit, + onUndoEnd: () -> Unit, +) { + if (!state.periodActive) { + if (!state.periodTrackingEnabled) return + val continues = state.continuesEpisodeStart + ListCard { + ListRow( + key = if (continues != null) "Log as a period day" else "Period started today", + onClick = onStartPeriod, + ) + } + if (continues != null) { + Text( + "Continues the period started ${continues.format(displayFormat)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + val pendingStart = state.startPeriodToday && state.episodeId == null + val endedToday = state.endDate != null && state.endDate == state.date && + state.loadedEndDate != state.endDate + val title = when { + pendingStart -> "Period starts today" + state.startPeriodToday -> "Period day added" + endedToday -> "Period ends today" + state.endDate == null -> "Period ongoing" + else -> "Period recorded" + } + val since = (state.episodeStart ?: state.date).format(displayFormat) + val subtitle = when { + pendingStart -> state.endDate?.let { "Until ${it.format(displayFormat)}" } ?: "Save to log it" + state.startPeriodToday -> "Continues the period started $since" + else -> "Since $since" + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier + .heightIn(min = 56.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = subtitle, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + when { + state.startPeriodToday -> TextButton(onClick = onUndoStart) { Text("Undo") } + endedToday -> TextButton(onClick = onUndoEnd) { Text("Undo") } + state.endDate == null -> TextButton(onClick = onEndPeriod) { Text("End") } + } + } + } +} + +// ── Switch sheet ────────────────────────────────────────────────────────────── + +/** + * The title/header switcher: every category organised by group and tinted by + * its role, so the colour you're about to log in is visible before you commit. + * + * Opened from the screen title it jumps between sections and offers a day + * change; opened from a metric header ([refileSourceId] set) it re-files the + * entered value under the picked category, keeping the value. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DaySwitchSheet( + refileSourceId: Long?, + state: LogUiState, + onPickDay: () -> Unit, + onPickCategory: (Long) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + val selectedId = refileSourceId ?: state.activeCategoryId + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .navigationBarsPadding() + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (refileSourceId != null) "File this entry under…" else "Switch day or category", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = if (refileSourceId != null) { + "The value you entered is kept; only the category it is filed under changes." + } else { + "Jump to a category, or pick another day to log." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (refileSourceId == null) { + ListCard { + ListRow( + key = "Change day", + value = state.date.format(displayFormat), + onClick = onPickDay, + ) + } + } + + val groupIds = state.groups.map { it.id }.toSet() + val byGroup = state.categories + .filter { cat -> cat.groupId.let { it != null && it in groupIds } } + .groupBy { it.groupId } + val ungrouped = state.categories + .filter { cat -> cat.groupId.let { it == null || it !in groupIds } } + + state.groups.forEach { group -> + val members = byGroup[group.id] ?: return@forEach + SheetGroupLabel(name = group.name, token = group.colorRole) + members.forEach { cat -> + SheetCategoryRow( + category = cat, + state = state, + selected = cat.id == selectedId, + onPick = { onPickCategory(cat.id) }, + ) + } + } + if (ungrouped.isNotEmpty()) { + if (byGroup.isNotEmpty()) SheetGroupLabel(name = "Other", token = null) + ungrouped.forEach { cat -> + SheetCategoryRow( + category = cat, + state = state, + selected = cat.id == selectedId, + onPick = { onPickCategory(cat.id) }, + ) + } + } + } + } +} + +@Composable +private fun SheetGroupLabel(name: String, token: String?) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + if (token != null) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(token.toCategoryColor()), + ) + } + Text( + text = name.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.11.em, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SheetCategoryRow( + category: TrackingCategory, + state: LogUiState, + selected: Boolean, + onPick: () -> Unit, +) { + val token = category.effectiveColorToken(state.groups) + val roleColor = token.toCategoryColor() + val container = if (selected) { + roleContainerTint(roleColor, MaterialTheme.colorScheme.surface) + } else Color.Transparent + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(container) + .semantics { + this.role = Role.RadioButton + this.selected = selected + } + .clickable(onClick = onPick) + .heightIn(min = 48.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RadioButton(selected = selected, onClick = null) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(roleColor), + ) + Text( + text = category.name, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} + +// ── Value mapping helpers ───────────────────────────────────────────────────── + +/** Maps a [DayMetricEntry] onto the [MetricValue] variant [MetricInput] expects. */ +private fun metricValueForEntry( + type: CategoryType, + config: MetricConfig, + entry: DayMetricEntry, +): MetricValue = when (type) { + CategoryType.DEFAULT -> MetricValue.Choice(entry.selectedValues) + CategoryType.NUMERIC_SLIDER -> + if (config.usesStepScale()) MetricValue.Scale(entry.numericValue?.toInt()) + else MetricValue.Continuous(entry.numericValue) + CategoryType.NUMERIC_FREE -> MetricValue.FreeNumber(entry.freeText) + CategoryType.INCREMENT -> MetricValue.Count(entry.numericValue?.toInt() ?: 0) + CategoryType.YES_NO -> MetricValue.YesNo( + when { + "Yes" in entry.selectedValues -> true + "No" in entry.selectedValues -> false + else -> null + } + ) + CategoryType.TIME -> MetricValue.TimeOfDay(entry.selectedValues.firstOrNull()) +} + +/** Words for the current reading, or null when nothing is set for the day. */ +private fun entrySummary( + category: TrackingCategory, + entry: DayMetricEntry, + config: MetricConfig, +): String? { + fun withUnit(text: String): String = + if (config.unit.isNullOrBlank()) text else "$text ${config.unit}" + + val type = category.categoryType.toCategoryType() + if (type == CategoryType.INCREMENT && category.trackAgainstTime) { + val n = entry.timedEntries.size + return if (n > 0) withUnit(n.toString()) else null + } + return when (type) { + CategoryType.NUMERIC_SLIDER -> entry.numericValue?.let { v -> + if (!category.allowDecimals) { + config.stepLabels[v.toInt()] ?: withUnit(v.toInt().toString()) + } else { + withUnit("%.1f".format(v)) + } + } + CategoryType.NUMERIC_FREE -> + entry.freeText.trim().takeIf { it.isNotEmpty() }?.let { withUnit(it) } + CategoryType.INCREMENT -> + entry.numericValue?.toInt()?.takeIf { it > 0 }?.let { withUnit(it.toString()) } + CategoryType.YES_NO -> when { + "Yes" in entry.selectedValues -> "Yes" + "No" in entry.selectedValues -> "No" + else -> null + } + CategoryType.TIME -> entry.selectedValues.firstOrNull() + CategoryType.DEFAULT -> when { + entry.selectedValues.isEmpty() -> null + entry.selectedValues.size > 2 -> "${entry.selectedValues.size} selected" + else -> entry.selectedValues.joinToString(", ") + } + } +} + +// ── Date picker ─────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerDialogWrapper( + initial: LocalDate, + minDate: LocalDate? = null, + onConfirm: (LocalDate) -> Unit, + onDismiss: () -> Unit, +) { + val initialMillis = initial.atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli() + val pickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + val millis = pickerState.selectedDateMillis ?: return@TextButton + val picked = Instant.ofEpochMilli(millis).atZone(ZoneId.of("UTC")).toLocalDate() + if (minDate == null || !picked.isBefore(minDate)) { + onConfirm(picked) + } + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = pickerState) + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt new file mode 100644 index 0000000..1f0438e --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt @@ -0,0 +1,752 @@ +package com.mapgie.goflo.ui.screens.log + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.mapgie.goflo.data.database.entities.Group +import com.mapgie.goflo.data.database.entities.PeriodEntry +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.database.entities.TrackingLog +import com.mapgie.goflo.data.database.entities.TrackingValue +import com.mapgie.goflo.data.preferences.AppPreferencesStore +import com.mapgie.goflo.data.repository.PeriodRepository +import com.mapgie.goflo.data.repository.TrackingLogWithValues +import com.mapgie.goflo.data.repository.TrackingRepository +import com.mapgie.goflo.notifications.ReminderScheduler +import com.mapgie.goflo.widget.GoFloWidget +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * The per-day state of one tracked category on the unified day screen. + * + * Mirrors the fields LogCategoryViewModel keeps for its single category, held + * once per category here. [touched] records whether the user changed anything + * this session: untouched entries are skipped on save, so the screen neither + * fabricates logs for ignored categories nor rewrites stored entries (which + * would re-stamp or clear their recorded time). Pinned categories are the + * exception while the day is on-period: they keep the period screen's + * always-save fan-out semantics. + */ +data class DayMetricEntry( + val selectedValues: Set = emptySet(), + /** Slider position or running count, per the category type. */ + val numericValue: Float? = null, + /** Text entry for numeric_free categories. */ + val freeText: String = "", + /** Per-log notes (500-char cap enforced by the screen). */ + val notes: String = "", + /** Whether to stamp the save with the current time (pre-set from trackAgainstTime). */ + val trackTime: Boolean = false, + val existingLog: TrackingLog? = null, + /** Timed entries already logged this day (increment + trackAgainstTime only). */ + val timedEntries: List = emptyList(), + val touched: Boolean = false, +) + +/** + * UI state for the unified day screen: one day, with an active period as a + * state of that day rather than a separate destination. + */ +data class LogUiState( + val isLoading: Boolean = true, + /** The day being logged or edited. */ + val date: LocalDate = LocalDate.now(), + val toleranceDays: Int = PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS, + val periodTrackingEnabled: Boolean = true, + + // ── Period state of the day ─────────────────────────────────────────────── + /** The episode covering (or within tolerance reach of) [date], if any. */ + val episodeId: Long? = null, + val episodeStart: LocalDate? = null, + /** Editable explicit episode end ("until"), null = open. */ + val endDate: LocalDate? = null, + /** The end date as loaded, so the End action can be undone before saving. */ + val loadedEndDate: LocalDate? = null, + /** True when [date] itself is a logged period day. */ + val isPeriodDay: Boolean = false, + /** True when [date] falls inside its episode's start..end span. */ + val dayInEpisode: Boolean = false, + /** + * Start of the episode [date] would continue (within gap tolerance) when + * the day is not itself on-period; null when logging would start fresh. + */ + val continuesEpisodeStart: LocalDate? = null, + /** 1-based day number of [date] within its episode, when known. */ + val episodeDayNumber: Int? = null, + /** The user tapped "Period started today"; applied on save. */ + val startPeriodToday: Boolean = false, + /** Episode-level notes (the period screen's Notes field). */ + val periodNotes: String = "", + + // ── Flow (rendered only while the day is on-period) ────────────────────── + val flowCategory: TrackingCategory? = null, + val flowCategoryName: String = "Flow", + val flowOptions: List = emptyList(), + val selectedFlowLabel: String = "Medium", + val flowSliderValue: Float? = null, + + // ── Symptoms ───────────────────────────────────────────────────────────── + val symptomsCategory: TrackingCategory? = null, + val symptomsCategoryName: String = "Symptoms", + val symptomOptions: List = emptyList(), + val symptoms: Set = emptySet(), + val symptomsTouched: Boolean = false, + + // ── Tracked categories (active, non-system) ────────────────────────────── + val groups: List = emptyList(), + val categories: List = emptyList(), + /** Catalog value labels per category id. */ + val categoryValues: Map> = emptyMap(), + /** Per-category day entries, keyed by category id. */ + val entries: Map = emptyMap(), + + // ── Screen state ───────────────────────────────────────────────────────── + /** The category whose input is currently expanded from a grouped card row. */ + val activeCategoryId: Long? = null, + val hasChanges: Boolean = false, + val saved: Boolean = false, + val deleted: Boolean = false, + val error: String? = null, +) { + /** Whether the day renders in its on-period arrangement. */ + val periodActive: Boolean + get() = isPeriodDay || dayInEpisode || startPeriodToday +} + +/** + * ViewModel for the unified `LogScreen(date)`. + * + * Period behaviour (episode continuation, the flow slider mapping, the save + * fan-out into the tracking system, widget and reminder refreshes) delegates + * to [PeriodDaySync] and [PeriodRepository], the same code paths + * [LogPeriodViewModel] uses, so the two surfaces cannot drift. Generic + * category behaviour mirrors [LogCategoryViewModel]'s save rules per entry. + */ +class LogViewModel( + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val initialDate: LocalDate, + private val application: Application? = null, + private val preferencesStore: AppPreferencesStore? = null, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LogUiState(date = initialDate)) + val uiState: StateFlow = _uiState.asStateFlow() + + private var optionSubscriptionsStarted = false + + init { + viewModelScope.launch { + val prefs = preferencesStore?.preferences?.first() + _uiState.update { + it.copy( + toleranceDays = prefs?.periodGapToleranceDays + ?: PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS, + periodTrackingEnabled = prefs?.periodTrackingEnabled ?: true, + ) + } + loadDay(initialDate) + _uiState.update { it.copy(isLoading = false) } + } + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + /** + * Loads (or reloads) everything the screen shows for [date]: the period + * context, the day's stored flow/symptom values, and one [DayMetricEntry] + * per active non-system category. Leaves the form pristine + * (hasChanges = false) because after a load it matches the stored state. + */ + private suspend fun loadDay(date: LocalDate) { + val tolerance = _uiState.value.toleranceDays + + // Period context: episode covering or within tolerance reach of the day. + val periods = repository.getAllPeriodsOnce() + val isPeriodDay = repository.isPeriodDay(date) + val episode = PeriodRepository.periodForDate(periods, date, tolerance) + val epStart = episode?.let { LocalDate.parse(it.startDate) } + val epEndStored = episode?.endDate?.let { LocalDate.parse(it) } + val dayInEpisode = epStart != null && + !date.isBefore(epStart) && + (epEndStored == null || !date.isAfter(epEndStored)) + // Opening a day just past the stored end (within tolerance) extends the + // end to that day, so saving continues the period instead of trimming + // the new day away — same rule as LogPeriodViewModel's init. + val effectiveEnd = + if (epEndStored != null && date.isAfter(epEndStored)) date else epEndStored + + // Flow + symptoms stored values for this day. + val flowCat = trackingRepository.getSystemCategoryByKey("flow") + val symptomsCat = trackingRepository.getSystemCategoryByKey("symptoms") + var editFlowLabel: String? = null + var editFlowSlider: Float? = null + if (flowCat != null) { + val raw = trackingRepository.getExistingLog(date, flowCat.id)?.values?.firstOrNull() + if (raw != null) { + if (flowCat.categoryType == "numeric_slider") { + editFlowSlider = raw.toFloatOrNull() + editFlowLabel = PeriodDaySync.flowLabelForSliderValue(editFlowSlider?.toInt() ?: 3) + } else { + editFlowLabel = raw + } + } + } + val editSymptoms = symptomsCat?.let { + trackingRepository.getExistingLog(date, it.id)?.values?.toSet() + } + val selectedFlow = editFlowLabel ?: "Medium" + val sliderValue = editFlowSlider ?: if (flowCat?.categoryType == "numeric_slider") { + PeriodDaySync.flowLabelToSliderValue(selectedFlow) + } else null + + // Tracked categories, their groups, catalogs, and this day's entries. + val groups = trackingRepository.getAllGroupsOnce() + val categories = trackingRepository.getActiveCategories().first().filter { !it.isSystem } + val valuesMap = mutableMapOf>() + val entriesMap = mutableMapOf() + for (cat in categories) { + valuesMap[cat.id] = trackingRepository.getValuesForCategoryOnce(cat.id).map { it.label } + entriesMap[cat.id] = loadEntry(cat, date) + } + + _uiState.update { state -> + state.copy( + date = date, + episodeId = episode?.id, + episodeStart = epStart, + endDate = effectiveEnd, + loadedEndDate = effectiveEnd, + isPeriodDay = isPeriodDay, + dayInEpisode = dayInEpisode, + continuesEpisodeStart = + if (epStart != null && !isPeriodDay && !dayInEpisode) epStart else null, + episodeDayNumber = epStart?.let { PeriodDaySync.dayNumber(minOf(it, date), date) }, + startPeriodToday = false, + periodNotes = episode?.notes ?: "", + flowCategory = flowCat, + flowCategoryName = flowCat?.name ?: state.flowCategoryName, + selectedFlowLabel = selectedFlow, + flowSliderValue = sliderValue, + symptomsCategory = symptomsCat, + symptomsCategoryName = symptomsCat?.name ?: state.symptomsCategoryName, + symptoms = editSymptoms ?: emptySet(), + symptomsTouched = false, + groups = groups, + categories = categories, + categoryValues = valuesMap, + entries = entriesMap, + activeCategoryId = null, + hasChanges = false, + ) + } + + // Keep the flow/symptom option chips live after catalog edits + // (e.g. the inline Add Symptom dialog). + if (!optionSubscriptionsStarted) { + optionSubscriptionsStarted = true + if (flowCat != null) { + viewModelScope.launch { + trackingRepository.getValuesForCategory(flowCat.id).collect { values -> + _uiState.update { it.copy(flowOptions = values) } + } + } + } + if (symptomsCat != null) { + viewModelScope.launch { + trackingRepository.getValuesForCategory(symptomsCat.id).collect { values -> + _uiState.update { it.copy(symptomOptions = values) } + } + } + } + } + } + + /** Loads one category's stored entry for [date] into a [DayMetricEntry]. */ + private suspend fun loadEntry(cat: TrackingCategory, date: LocalDate): DayMetricEntry { + val timed = cat.categoryType == "increment" && cat.trackAgainstTime + val timedEntries = + if (timed) trackingRepository.getLogsForDateAndCategory(date, cat.id) else emptyList() + // allowMultiple categories always start a fresh entry, matching + // LogCategoryViewModel's new-entry behaviour. + val existing = if (timed || cat.allowMultiple) null + else trackingRepository.getExistingLog(date, cat.id) + val numeric = + if (cat.categoryType == "numeric_slider" || cat.categoryType == "increment") + existing?.values?.firstOrNull()?.toFloatOrNull() + else null + val freeText = if (cat.categoryType == "numeric_free") + existing?.values?.firstOrNull() ?: "" else "" + return DayMetricEntry( + selectedValues = existing?.values?.toSet() ?: emptySet(), + numericValue = numeric, + freeText = freeText, + notes = existing?.log?.notes ?: "", + trackTime = cat.trackAgainstTime, + existingLog = existing?.log, + timedEntries = timedEntries, + ) + } + + private suspend fun reloadEntry(categoryId: Long) { + val state = _uiState.value + val cat = state.categories.firstOrNull { it.id == categoryId } ?: return + val fresh = loadEntry(cat, state.date) + _uiState.update { it.copy(entries = it.entries + (categoryId to fresh)) } + } + + // ── Day switching ───────────────────────────────────────────────────────── + + /** + * Changes the day being shown and reloads every section's stored values + * for it, so the form always reflects the selected day (the same rule as + * LogCategoryViewModel.setDate). Unsaved edits are guarded by the screen + * before this is called. + */ + fun setDate(newDate: LocalDate) { + if (newDate == _uiState.value.date) return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + loadDay(newDate) + _uiState.update { it.copy(isLoading = false) } + } + } + + // ── Period actions ──────────────────────────────────────────────────────── + + /** Marks the day to be logged as a period day (starting or continuing one) on save. */ + fun startPeriodToday() = _uiState.update { + it.copy(startPeriodToday = true, hasChanges = true) + } + + fun undoStartPeriod() = _uiState.update { + it.copy(startPeriodToday = false, hasChanges = true) + } + + /** Moves the episode start (existing episodes only). */ + fun setStartDate(date: LocalDate) = _uiState.update { state -> + if (state.episodeId == null) return@update state + val end = if (state.endDate != null && date.isAfter(state.endDate)) null else state.endDate + state.copy( + episodeStart = date, + endDate = end, + episodeDayNumber = PeriodDaySync.dayNumber(date, state.date), + hasChanges = true, + ) + } + + fun setEndDate(date: LocalDate?) = _uiState.update { + it.copy(endDate = date, hasChanges = true) + } + + /** The footer's End action: close the period on the day being logged. */ + fun endPeriodOnThisDay() = _uiState.update { + it.copy(endDate = it.date, hasChanges = true) + } + + /** Undoes the End action, restoring the end date as loaded. */ + fun undoEndPeriod() = _uiState.update { + it.copy(endDate = it.loadedEndDate, hasChanges = true) + } + + fun setFlowLevel(label: String) = _uiState.update { + it.copy(selectedFlowLabel = label, hasChanges = true) + } + + fun setFlowSliderValue(value: Float) = _uiState.update { state -> + state.copy( + flowSliderValue = value, + selectedFlowLabel = PeriodDaySync.flowLabelForSliderValue(value.toInt()), + hasChanges = true, + ) + } + + fun toggleSymptom(label: String) = _uiState.update { state -> + val updated = if (label in state.symptoms) state.symptoms - label else state.symptoms + label + state.copy(symptoms = updated, symptomsTouched = true, hasChanges = true) + } + + /** + * Adds [name] as a new option in the symptoms catalog and selects it for + * this day. The catalog insert is fire-and-forget; the selection is + * immediate. Same behaviour as the period screen's Add Symptom dialog. + */ + fun addNewSymptomToLibrary(name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + val sympCat = trackingRepository.getSystemCategoryByKey("symptoms") ?: return@launch + trackingRepository.addValueToCategory(sympCat.id, trimmed) + } + _uiState.update { state -> + state.copy(symptoms = state.symptoms + trimmed, symptomsTouched = true, hasChanges = true) + } + } + + fun setPeriodNotes(notes: String) = _uiState.update { + it.copy(periodNotes = notes, hasChanges = true) + } + + fun disablePeriodTracking() { + viewModelScope.launch { preferencesStore?.setPeriodTrackingEnabled(false) } + } + + // ── Category entry mutators ─────────────────────────────────────────────── + + private fun updateEntry(categoryId: Long, transform: (DayMetricEntry) -> DayMetricEntry) = + _uiState.update { state -> + val entry = state.entries[categoryId] ?: DayMetricEntry() + state.copy( + entries = state.entries + (categoryId to transform(entry)), + hasChanges = true, + ) + } + + fun toggleEntryValue(categoryId: Long, label: String) = updateEntry(categoryId) { entry -> + val selected = if (label in entry.selectedValues) entry.selectedValues - label + else entry.selectedValues + label + entry.copy(selectedValues = selected, touched = true) + } + + /** Replaces the whole selection set (chip sets, and yes_no/time single labels). */ + fun setEntrySelection(categoryId: Long, values: Set) = updateEntry(categoryId) { + it.copy(selectedValues = values, touched = true) + } + + fun setEntryNumeric(categoryId: Long, value: Float) = updateEntry(categoryId) { + it.copy(numericValue = value, touched = true) + } + + fun setEntryFreeText(categoryId: Long, text: String) = updateEntry(categoryId) { + it.copy(freeText = text, touched = true) + } + + fun setEntryNotes(categoryId: Long, notes: String) = updateEntry(categoryId) { + it.copy(notes = notes, touched = true) + } + + fun setEntryTrackTime(categoryId: Long, track: Boolean) = updateEntry(categoryId) { + it.copy(trackTime = track, touched = true) + } + + fun setActiveCategory(categoryId: Long?) = _uiState.update { + it.copy(activeCategoryId = categoryId) + } + + /** Deletes a category's existing log for this day and reloads its entry. */ + fun deleteEntry(categoryId: Long) { + val log = _uiState.value.entries[categoryId]?.existingLog ?: return + viewModelScope.launch { + runCatching { + trackingRepository.deleteLog(log) + reloadEntry(categoryId) + }.onFailure { + _uiState.update { s -> s.copy(error = "Could not delete the entry. Please try again.") } + } + } + } + + /** Adds a new time-stamped increment entry immediately (increment + trackAgainstTime). */ + fun addTimedIncrement(categoryId: Long) { + val state = _uiState.value + if (state.isLoading) return + val time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + viewModelScope.launch { + runCatching { + trackingRepository.saveLog( + date = state.date, + categoryId = categoryId, + selectedValues = setOf("1"), + notes = "", + allowMultiple = true, + loggedAt = time, + ) + reloadEntry(categoryId) + }.onFailure { + _uiState.update { s -> s.copy(error = "Could not log the entry. Please try again.") } + } + } + } + + /** Deletes a specific timed entry (increment + trackAgainstTime undo). */ + fun deleteTimedEntry(categoryId: Long, log: TrackingLog) { + viewModelScope.launch { + runCatching { + trackingRepository.deleteLog(log) + reloadEntry(categoryId) + }.onFailure { + _uiState.update { s -> s.copy(error = "Could not delete the entry. Please try again.") } + } + } + } + + // ── Re-filing (the header switcher) ────────────────────────────────────── + + /** + * Re-files the value entered under [fromId] to the category [toId]: the + * entered (unsaved) value is carried over, serialised through the same + * rules a save would use, and the source entry reverts to its stored + * state. Switching only changes what the entry is filed under; nothing is + * saved until Save. When the source has no unsaved edits this is a plain + * focus switch. + */ + fun refileEntry(fromId: Long, toId: Long) { + val state = _uiState.value + if (fromId == toId) { + _uiState.update { it.copy(activeCategoryId = toId) } + return + } + val fromCat = state.categories.firstOrNull { it.id == fromId } + val toCat = state.categories.firstOrNull { it.id == toId } + val fromEntry = state.entries[fromId] + if (fromCat == null || toCat == null || fromEntry == null || !fromEntry.touched) { + _uiState.update { it.copy(activeCategoryId = toId) } + return + } + val labels = serialisedValues(fromCat, fromEntry) + viewModelScope.launch { + val reset = loadEntry(fromCat, state.date) + _uiState.update { s -> + val target = s.entries[toId] ?: DayMetricEntry(trackTime = toCat.trackAgainstTime) + val refiled = if (labels.isNullOrEmpty()) target else hydrateEntry(toCat, labels, target) + s.copy( + entries = s.entries + (fromId to reset) + (toId to refiled), + activeCategoryId = toId, + hasChanges = true, + ) + } + } + } + + /** Serialises an entry's current value to the labels a save would store. */ + private fun serialisedValues(cat: TrackingCategory, entry: DayMetricEntry): Set? = + when (cat.categoryType) { + "numeric_slider" -> entry.numericValue?.let { + setOf(formatNumericValue(it, cat.allowDecimals)) + } + "numeric_free" -> entry.freeText.trim().takeIf { it.isNotEmpty() }?.let { setOf(it) } + "increment" -> entry.numericValue?.toInt()?.takeIf { it > 0 }?.let { setOf(it.toString()) } + else -> entry.selectedValues.takeIf { it.isNotEmpty() } + } + + /** Hydrates stored-shape labels into the state fields [cat]'s input reads. */ + private fun hydrateEntry( + cat: TrackingCategory, + labels: Set, + base: DayMetricEntry, + ): DayMetricEntry = when (cat.categoryType) { + "numeric_slider", "increment" -> + base.copy(numericValue = labels.firstOrNull()?.toFloatOrNull(), touched = true) + "numeric_free" -> + base.copy(freeText = labels.firstOrNull() ?: "", touched = true) + else -> + base.copy(selectedValues = labels, touched = true) + } + + // ── Saving ──────────────────────────────────────────────────────────────── + + /** + * Saves the day. When the day is on-period (or being started), the period + * path mirrors LogPeriodViewModel.save(): mark the day, apply episode + * boundary edits, write episode meta, then fan the day's flow out to the + * tracking system, and refresh widgets and prediction reminders. Symptoms + * and every tracked category then save through the shared per-day rules. + */ + fun save() { + val state = _uiState.value + if (state.isLoading) return + viewModelScope.launch { + try { + val tolerance = state.toleranceDays + val periodSave = state.periodActive + if (periodSave) { + val episode: PeriodEntry? = if (state.episodeId != null) { + repository.logPeriodDay(state.date, tolerance) + repository.updateEpisode( + id = state.episodeId, + start = state.episodeStart ?: state.date, + end = state.endDate, + notes = state.periodNotes, + toleranceDays = tolerance, + ) + } else if (state.endDate != null && !state.endDate.isBefore(state.date)) { + repository.logPeriodRange(state.date, state.endDate, tolerance) + } else { + repository.logPeriodDay(state.date, tolerance) + } + if (episode != null) { + repository.updateEpisodeMeta( + id = episode.id, + notes = state.periodNotes, + flowLevel = state.selectedFlowLabel, + ) + } + PeriodDaySync.syncFlowToTrackingLog( + trackingRepository, state.date, state.selectedFlowLabel, state.flowSliderValue, + ) + } + + // Symptoms: period saves always mirror the set (parity with the + // period screen, where an emptied set deletes the day's log); + // otherwise only when the user touched them, so an off-period + // save never rewrites an untouched symptoms log. + if (periodSave || state.symptomsTouched) { + PeriodDaySync.syncSymptomsToTrackingLog( + trackingRepository, state.date, state.symptoms, + ) + } + + saveCategoryEntries(state, periodSave) + + if (periodSave) { + application?.let { GoFloWidget.updateAllWidgets(it) } + // Saving a period day changes the cycle predictions; failure + // must not report the (already successful) save as failed. + application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } + } + _uiState.update { it.copy(saved = true) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Could not save entry. Please try again.") } + } + } + } + + private suspend fun saveCategoryEntries(state: LogUiState, periodSave: Boolean) { + for (cat in state.categories) { + // Timed increments save per tap; never through the day save. + if (cat.categoryType == "increment" && cat.trackAgainstTime) continue + val entry = state.entries[cat.id] ?: continue + val pinnedContext = periodSave && cat.showInLogPeriod + val values: Set? = if (pinnedContext) { + // Exact parity with the period screen's pinned fan-out + // (slider falls back to min, count saves including 0). + PeriodDaySync.computePinnedValues( + cat, entry.numericValue, entry.freeText, entry.selectedValues, + ) + } else { + // Only touched entries save: an ignored category must neither + // gain a fabricated log nor have its stored entry rewritten + // (rewriting would re-stamp or clear its recorded time). + if (!entry.touched) null + else entryValuesToSave(cat, entry) + } + if (values == null) continue + val loggedAt = if (entry.trackTime) { + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + } else "" + val existing = entry.existingLog + if (existing != null) { + trackingRepository.updateLogInPlace(existing, values, entry.notes, loggedAt) + } else { + trackingRepository.saveLog( + date = state.date, + categoryId = cat.id, + selectedValues = values, + notes = entry.notes, + // The period screen's pinned fan-out always upserts the + // day's single log (allowMultiple forced off) — keep that, + // so repeated period-day saves never stack duplicates. + allowMultiple = if (pinnedContext) false else cat.allowMultiple, + loggedAt = loggedAt, + ) + } + } + } + + /** + * LogCategoryViewModel.save()'s per-type rules, applied per entry: an + * unset slider falls back to its displayed minimum, empty free numeric + * input and a count of zero or less record nothing (the old screen blocks + * those saves; here the category is skipped and any existing log is left + * untouched), and label types persist the selection set. + */ + private fun entryValuesToSave(cat: TrackingCategory, entry: DayMetricEntry): Set? = + when (cat.categoryType) { + "numeric_slider" -> { + val v = entry.numericValue ?: cat.numericMin + setOf(formatNumericValue(v, cat.allowDecimals)) + } + "numeric_free" -> { + val text = entry.freeText.trim() + if (text.isEmpty()) null else setOf(text) + } + "increment" -> { + val count = entry.numericValue?.toInt() ?: 0 + if (count <= 0) null else setOf(count.toString()) + } + // default chips, yes_no ("Yes"/"No") and time ("HH:mm") persist + // their labels straight from the selection set. + else -> entry.selectedValues + } + + private fun formatNumericValue(v: Float, allowDecimals: Boolean): String = + if (allowDecimals) "%.1f".format(v) else v.toInt().toString() + + // ── Period day removal and episode deletion ─────────────────────────────── + + /** + * Removes this day from the period without touching the day's own tracking + * logs — a flow or symptom logged on a day that turns out not to be a + * period day is still a valid, dated record. + */ + fun removeDay() { + val state = _uiState.value + viewModelScope.launch { + try { + repository.unlogPeriodDay(state.date, state.toleranceDays) + application?.let { GoFloWidget.updateAllWidgets(it) } + application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } + _uiState.update { it.copy(deleted = true) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Could not remove this day. Please try again.") } + } + } + } + + /** Deletes the entire episode: its days, its row, and its per-day logs. */ + fun deleteEpisode() { + val state = _uiState.value + val id = state.episodeId ?: return + viewModelScope.launch { + try { + val period = repository.getPeriodById(id).first() ?: return@launch + val days = repository.getDaysForEpisode(period, state.toleranceDays) + trackingRepository.deleteLogsForPeriod( + LocalDate.parse(period.startDate), + period.endDate?.let { LocalDate.parse(it) } + ?: days.lastOrNull()?.let { LocalDate.parse(it) }, + ) + repository.deletePeriod(period, state.toleranceDays) + application?.let { GoFloWidget.updateAllWidgets(it) } + application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } + _uiState.update { it.copy(deleted = true) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Could not delete entry. Please try again.") } + } + } + } + + fun clearError() = _uiState.update { it.copy(error = null) } + + class Factory( + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val date: LocalDate, + private val application: Application? = null, + private val preferencesStore: AppPreferencesStore? = null, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return LogViewModel(repository, trackingRepository, date, application, preferencesStore) as T + } + } +} From b2cf50da1024120c40b2e6db40a089edb05d44ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:26:02 +0000 Subject: [PATCH 6/8] Phase 5 bookkeeping: progress log, map drift note, changelog, lesson Marks Phase 5 done in PLAN.md with its deviations, records the new screen and the PeriodDaySync extraction in subsystem map 01, adds the minor-bump changelog fragment, and adds a lesson on translating single-entry save blocking into per-entry skip rules on a batch save surface. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- LESSONS.md | 3 +++ changelog/unreleased/unified-day-log-screen.json | 7 +++++++ docs/design/logging-redesign/PLAN.md | 2 +- .../logging-redesign/subsystem-maps/01-logging-screens.md | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/unified-day-log-screen.json diff --git a/LESSONS.md b/LESSONS.md index 14a48ac..e948cbe 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -35,6 +35,9 @@ The constructor has no default values in this version — passing only `shouldDi **A classification defined by negation ("anything but X") silently misclassifies new variants** `TrackingCategory.isNumeric` was `categoryType != "default"`, which was correct while every non-default type happened to store numbers. Adding the label-valued "yes_no" and "time" types would have silently routed "Yes"/"HH:mm" strings into numeric chart math (`toFloatOrNull()` returning null everywhere) with no compile error, because a negated predicate auto-includes every future variant. When a derived property gates behaviour, define membership positively (enumerate the types that ARE numeric); then a new variant defaults to the safe side and the property's KDoc records why. Grep for `!=` against discriminator fields whenever adding a variant to a string-keyed or enum type. +**A batch save surface must re-derive per-entry rules from the single-entry screen it replaces — "block the save" becomes "skip the entry", and "untouched" must be distinguished from "empty"** +A screen that saves one entry can block its Save button on invalid input (empty numeric field, zero count). A unified surface that saves many entries at once cannot block the whole save on one bad entry — each single-entry blocking rule must be translated to "skip this entry, leave any stored log untouched". The batch surface also introduces a state the single screen never had: an entry the user never interacted with. Saving those with their displayed defaults fabricates logs for every category on every save; track a per-entry `touched` flag and only persist entries that are touched or already stored. Exception: preserve any existing always-save semantics verbatim (GoFlo's pinned-category period fan-out deliberately saves untouched pinned entries), or the two surfaces silently produce different data for the same user action. + **Parallel write paths must each respect every category setting** When two code paths write to the same store (e.g. `LogPeriodViewModel.syncSymptomsToTrackingLog` and `LogCategoryViewModel.save` both writing to `tracking_logs`), each path must independently read and apply every relevant category flag. If a new flag is added (like `trackAgainstTime`) and only one path is updated, the other silently ignores the setting. When adding a per-category behaviour flag, grep for all call sites of the underlying `saveLog` / `updateLogInPlace` and confirm they all handle the new flag. diff --git a/changelog/unreleased/unified-day-log-screen.json b/changelog/unreleased/unified-day-log-screen.json new file mode 100644 index 0000000..be7454b --- /dev/null +++ b/changelog/unreleased/unified-day-log-screen.json @@ -0,0 +1,7 @@ +{ + "bump": "minor", + "added": [ + "New unified day log screen (preview): period, flow, symptoms, and every tracked category for a day in one place, opened from the day sheet on the calendar", + "Re-file an entry from the day log: tap a category's name to file the value you entered under a different category, organised by group" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index e41dcf8..092d527 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -207,7 +207,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 2 — Group data model | Done | `claude/logging-redesign-phase-2-nuaywv` | 24 | Colour-inheritance deviation (keep existing colours, opt-in `"inherit"`) confirmed with owner. `"inherit"` is a sentinel constant, not a `CategoryColor` entry, so the Phase 1 picker does not offer it. Group methods live in `TrackingRepository` (new nullable `groupDao` ctor param) rather than a separate repository. No FK on `groupId`; `deleteGroup` unfiles members first. Migration test is a JVM test (`Migration23To24Test`) driving the real migration through sqlite-jdbc, since the project has no instrumented tests and `exportSchema = false`. | | 3 — Component library | Done | `claude/logging-redesign-phase-3` | 24 (unchanged) | 12 primitives + `MetricInput` stub in `ui/components/`, previews only — no screen consumes them yet. Deviations: `RolePicker` built standalone (visuals mirrored from the Phase 1 picker; `ManageCategoriesScreen` left untouched per this phase's "existing screens compile unchanged" rule — rewire in Phase 6/7); tonal container fills for arbitrary role colours derived via `roleContainerTint` (lerp toward surface, house `ordinalShade` pattern) since M3 `ColorScheme` has no containers for derived/fixed roles; `ToneHero` word uses onSurface on the tint (contrast-safe in every palette) with the role carried by the container; `StepScale` gained a `name` param for its single-control announcement plus per-step `customActions` so TalkBack can still operate it. `MetricValue`/`MetricConfig` value types defined now, incl. YesNo/TimeOfDay variants for Phase 4. | | 4 — MetricInput + Yes/No + Time | Done | `claude/logging-redesign-phase-4` | 24 (unchanged) | §8 decision #3 resolved by the owner: Yes/No and Time store value-label strings ("Yes"/"No"; 24h "HH:mm") in `tracking_log_values` — no new columns, no migration. `LogCategoryScreen` renders every non-timed type through `MetricInput` (timed increment stays screen-driven, now rendering the `Timeline` primitive); rating scales ≤10 whole steps render as `StepScale` (plan §2 rule 1), wider/decimal ranges keep the parity slider incl. stepped whole-number behaviour. `TrackingCategory.isNumeric` re-defined from "not default" to an explicit numeric-type list so yes_no/time chart as label categories in Stats. `PinnedCategoryInput` gained additive yes_no/time branches delegating to `MetricInput` (existing four branches untouched; full replacement stays Phase 5). New `TimeField` primitive added to `ui/components/`. Editing a yes_no/time category still opens the default value-catalog editor (harmless; redesigned in Phase 7). | -| 5 — Unified LogScreen | Not started | | | Consider sub-PRs. | +| 5 — Unified LogScreen | Done | `claude/logging-redesign-phase-5` | 24 (unchanged) | New `LogScreen(date)` + `LogViewModel` behind additive route `log_day?date={date}`; LogPeriod/LogCategory routes untouched and all entry points still use them — the only new entry is an opt-in "Try the new day log (preview)" row in `DayLogSheet` (5d entry-point flip deliberately deferred). Period logic shared with `LogPeriodViewModel` via extracted `PeriodDaySync` (flow mapping, flow/symptom sync, pinned-value rules) rather than copied. Deviations: (1) re-file opens from the metric's own header (name is the button) as well as the screen title — on a whole-day surface the title sheet is day-switch + jump, and re-filing from an entry's own name is unambiguous; incompatible input shapes transfer via the serialised value labels. (2) Day-level Notes bind to episode notes and so render only while the day is on-period; per-log notes are editable per metric ("Add note"). (3) Off-period saves only write categories the user touched (no fabricated logs); pinned categories keep the exact period-screen fan-out semantics while on-period. (4) allowMultiple (non-timed) categories always start a fresh entry on the day screen (matching LogCategoryViewModel new-entry behaviour); editing a specific one of several same-day logs stays on LogCategory via the day sheet. | | 6 — What You Track home | Not started | | | | | 7 — Create/edit + scale + alarms | Not started | | | Decide categoryType mutability. | | 8 — Cleanup & removal | Not started | | | Gate on parity checklist. | diff --git a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md index 2b96c0d..43fe83b 100644 --- a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md +++ b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md @@ -9,6 +9,8 @@ > > **Phase 4 drift (branch `claude/logging-redesign-phase-4`):** `LogCategoryScreen` no longer contains the per-type section composables described in §2 — every non-timed input renders through the `MetricInput` facade (`ui/components/MetricInput.kt`), and the timed-increment path renders the `Timeline` primitive via a screen-level `TimedIncrementTimeline`. The screen keeps a small `when` only to map `LogCategoryUiState` onto a `MetricValue` (`metricValueFor`) and to frame card vs bare-chip layouts. Two new `categoryType` strings exist: `"yes_no"` (stores "Yes"/"No" value labels) and `"time"` (stores 24h "HH:mm" value labels); both flow through `LogCategoryUiState.selectedValues` as a single-label set, and `LogCategoryViewModel.save()`'s else-branch persists them. `PinnedCategoryInput` in `LogPeriodScreen` gained additive `"yes_no"`/`"time"` branches delegating to `MetricInput` (its four pre-existing branches and `LogPeriodViewModel.computePinnedValues` are unchanged; the new types save through the existing else/selection-set path plus a new `setPinnedSingleValue`). Line numbers below refer to the pre-Phase-4 files; the save-flow description in §4 remains accurate. +> **Phase 5 drift (branch `claude/logging-redesign-phase-5`):** a third, additive destination now exists: the unified day screen `LogScreen` (`ui/screens/log/LogScreen.kt`) + `LogViewModel`, route `log_day?date={date}` (`Screen.LogDay`), reached only via an opt-in "Try the new day log (preview)" row in `DayLogSheet` — every pre-existing entry point still targets the two screens below, and both remain registered and byte-for-byte functional. `LogViewModel` holds one `DayMetricEntry` per active non-system category plus the period-day state (episode continuation, flow, symptoms, episode notes) and reuses the period logic through `PeriodDaySync` (`ui/screens/log/PeriodDaySync.kt`), an extraction of `LogPeriodViewModel`'s former private helpers: the 1→Spotting/2→Light/4→Heavy/else-Medium flow mapping, `syncFlowToTrackingLog`, `syncSymptomsToTrackingLog`, and the pinned-category value rules (`computePinnedValues`). `LogPeriodViewModel` now delegates to that object; its public behaviour is unchanged. `LogCategoryScreen`'s `metricConfigFor` and `TimedIncrementTimeline` were widened from `private` to `internal` so `LogScreen` renders the identical config mapping and timed-increment surface. The §4 save-flow description applies to the unified screen as follows: on-period saves run the LogPeriodViewModel sequence (day + episode + meta + fan-out + widget/reminder refresh) and pinned categories keep `computePinnedValues` semantics; off-period saves write only touched categories using `LogCategoryViewModel.save()`'s per-type rules (empty free text / count ≤ 0 skip that category instead of blocking the day). + ## Overview: two truly separate destinations "Log Period" and "Log Category" are **fully separate screens, routes, ViewModels, and repositories**. They share only two small helpers: the `LogEntryTopBar` composable and a private (duplicated) `DatePickerDialogWrapper`. Period logging is a bespoke multi-section day editor on `PeriodRepository`; category logging is a single generic input on `TrackingRepository`. From 94fb3f8401b85d1867490a110a23af5fa4c17262 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:26:27 +0000 Subject: [PATCH 7/8] Fix unresolved semantics property references in the component library CI failed compileDebugKotlin on PR #180: role/selected/contentDescription/ customActions were assigned inside semantics{} blocks as this.role etc. without importing the corresponding androidx.compose.ui.semantics extension properties (importing the Role class does not cover the lowercase role property). Adds the missing imports to IconPicker, ListCard, RolePicker, StepScale, and Timeline, and extends the LESSONS.md shadowing entry with the import requirement. Verified with a semantics-vs-imports sweep over every main-source Kotlin file plus a11y_check.py. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- LESSONS.md | 2 +- app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt | 3 +++ app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt | 1 + app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt | 3 +++ app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt | 3 +++ app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt | 1 + 6 files changed, 12 insertions(+), 1 deletion(-) diff --git a/LESSONS.md b/LESSONS.md index e1b2aa5..a5f7999 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -9,7 +9,7 @@ Entries within each section are ordered by risk to a new project if forgotten: b ### Android / Compose **A `role: Color` parameter shadows the `role` semantics property — qualify with `this.role` inside `semantics {}`** -Components that take the category's colour as a `role: Color` parameter break the idiomatic `semantics { role = Role.Button }` assignment: inside the lambda, the enclosing function's `role` parameter shadows the `SemanticsPropertyReceiver.role` extension property, so the unqualified assignment tries to reassign the `val` parameter and fails to compile. Write `this.role = Role.Button` (and likewise `this.selected` / `this.contentDescription` when locals share those names) inside `semantics {}` and `clearAndSetSemantics {}` blocks. The qualified form still satisfies `a11y_check.py`'s pattern match. +Components that take the category's colour as a `role: Color` parameter break the idiomatic `semantics { role = Role.Button }` assignment: inside the lambda, the enclosing function's `role` parameter shadows the `SemanticsPropertyReceiver.role` extension property, so the unqualified assignment tries to reassign the `val` parameter and fails to compile. Write `this.role = Role.Button` (and likewise `this.selected` / `this.contentDescription` when locals share those names) inside `semantics {}` and `clearAndSetSemantics {}` blocks. The qualified form still satisfies `a11y_check.py`'s pattern match. Crucially, `this.role` still needs the extension property imported: each semantics property is a top-level extension in `androidx.compose.ui.semantics` (`import androidx.compose.ui.semantics.role`, `.selected`, `.contentDescription`, `.customActions`, ...), and importing the `Role` class does NOT cover the lowercase `role` property — omitting them fails only at compile time ("Unresolved reference"), which a build-less environment won't catch. When writing semantics blocks without a compiler, check every property assigned in the block against the file's import list. **`SwipeToDismissBox`: use `confirmValueChange` returning `false` to intercept — not `LaunchedEffect` + `reset()`** When a swipe should show a confirmation dialog before committing, the natural-looking approach is `confirmValueChange = { true }` (allow the state change) then call `state.reset()` in the dialog's Cancel handler. This causes two bugs: (1) if the composition survives navigation, the `LaunchedEffect` key hasn't changed on return so the dialog silently re-appears; (2) `reset()` takes one animation frame, leaving a brief window where swiping is disabled. The correct pattern is `confirmValueChange = { newValue -> if (newValue == EndToStart) { showConfirm = true; false } else true }`. Returning `false` rejects the transition entirely — the box springs back immediately, no `reset()` call is needed, and the dialog fully controls the outcome. Remove the `LaunchedEffect` and the coroutine scope from the composable. diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt b/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt index 86b32a7..4a4f8eb 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt @@ -18,6 +18,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt index 78931cc..ba7e9a1 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt index 13eef95..d967fe9 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt @@ -26,6 +26,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt b/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt index 0dfe800..a47c458 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt @@ -21,6 +21,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt b/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt index b0b813b..7c90577 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight From e6353b048321fd4d3496fc5cca65591e761ff4c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:26:57 +0000 Subject: [PATCH 8/8] Merge phase-3 semantics-import fix; fix the same missing import in TimeField Brings in the phase-3 fix for unresolved semantics extension properties and applies the same fix to TimeField.kt (Phase 4 file, missing the lowercase androidx.compose.ui.semantics.role import), found by sweeping every main-source file with the same checker. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt b/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt index bdb828a..2924f81 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/TimeField.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight