From 63fa6bd50dbd0301a135c58f30356ebec38aad45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 00:50:38 +0000 Subject: [PATCH 1/3] 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/3] 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 94fb3f8401b85d1867490a110a23af5fa4c17262 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:26:27 +0000 Subject: [PATCH 3/3] 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