From 0dbec521c9b03d0ec11a8188e7b01934cb98ba7e Mon Sep 17 00:00:00 2001 From: Markel15 Date: Thu, 20 Aug 2026 22:26:29 +0200 Subject: [PATCH 01/14] feat: update db to allow different scheduled days for the habits The idea behind this is that each habit doesn't have the same scheduled days to complete as others. For example your main goal may be to work out only 3 days a week and not every day. So to keep better the streaks and add more options to the users I thought that this could be a great idea. This is something similar to what was asked in the #18 issue. I think this is the best way to get the option to achieve those options like having something to do 1 day a week. For a habit I think this is the best way, I could extend or add some way to add something to do like once every 2 weeks or monthly, but I think that suits more a task (I'm thinking of adding recurring tasks to the flow module (not yet, but when I remember this I think is the best place to add it)). --- .../20.json | 539 ++++++++++++++++++ .../core/data/HabitRepositoryImpl.kt | 9 +- .../core/data/backup/ExportSchema.kt | 5 +- .../flowstate/core/data/backup/Mappers.kt | 10 +- .../flowstate/core/data/di/DatabaseModule.kt | 2 +- .../core/data/local/FlowStateDatabase.kt | 116 +++- .../core/data/local/HabitConverters.kt | 30 + .../flowstate/core/data/local/HabitEntity.kt | 7 +- .../core/data/BackupRepositoryImplTest.kt | 5 +- .../core/data/HabitConvertersTest.kt | 46 ++ .../markel/flowstate/core/data/MappersTest.kt | 29 +- .../com/markel/flowstate/core/domain/Habit.kt | 6 +- 12 files changed, 772 insertions(+), 32 deletions(-) create mode 100644 core/data/schemas/com.markel.flowstate.core.data.local.FlowStateDatabase/20.json create mode 100644 core/data/src/main/java/com/markel/flowstate/core/data/local/HabitConverters.kt create mode 100644 core/data/src/test/java/com/markel/flowstate/core/data/HabitConvertersTest.kt diff --git a/core/data/schemas/com.markel.flowstate.core.data.local.FlowStateDatabase/20.json b/core/data/schemas/com.markel.flowstate.core.data.local.FlowStateDatabase/20.json new file mode 100644 index 00000000..f92b8fc8 --- /dev/null +++ b/core/data/schemas/com.markel.flowstate.core.data.local.FlowStateDatabase/20.json @@ -0,0 +1,539 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "b54bd27ed01315f4a10f6490cd7bf7ab", + "entities": [ + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `isDone` INTEGER NOT NULL, `position` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `dueDate` INTEGER, `completedAt` INTEGER, `reminderTime` INTEGER, `categoryId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subtasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskId` INTEGER NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `isDone` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `dueDate` INTEGER, `position` INTEGER NOT NULL, `completedAt` INTEGER, `reminderTime` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subtasks_taskId", + "unique": false, + "columnNames": [ + "taskId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `${TABLE_NAME}` (`taskId`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "taskId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ideas", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `color` INTEGER NOT NULL, `position` INTEGER NOT NULL, `categoryId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "checklists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `color` INTEGER NOT NULL, `position` INTEGER NOT NULL, `categoryId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "checklist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `listId` INTEGER NOT NULL, `text` TEXT NOT NULL, `isDone` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`listId`) REFERENCES `checklists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "checklists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "listId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "habits", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `iconName` TEXT NOT NULL, `colorArgb` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `habitType` TEXT NOT NULL, `unit` TEXT, `targetValue` REAL, `step` REAL NOT NULL, `position` INTEGER NOT NULL, `scheduledDays` TEXT NOT NULL DEFAULT 'MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconName", + "columnName": "iconName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorArgb", + "columnName": "colorArgb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "habitType", + "columnName": "habitType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT" + }, + { + "fieldPath": "targetValue", + "columnName": "targetValue", + "affinity": "REAL" + }, + { + "fieldPath": "step", + "columnName": "step", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledDays", + "columnName": "scheduledDays", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "habit_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `habitId` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, FOREIGN KEY(`habitId`) REFERENCES `habits`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "habitId", + "columnName": "habitId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_habit_entries_habitId", + "unique": false, + "columnNames": [ + "habitId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_habit_entries_habitId` ON `${TABLE_NAME}` (`habitId`)" + } + ], + "foreignKeys": [ + { + "table": "habits", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "habitId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "habit_numeric_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`habitId` INTEGER NOT NULL, `epochDay` INTEGER NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`habitId`, `epochDay`), FOREIGN KEY(`habitId`) REFERENCES `habits`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "habitId", + "columnName": "habitId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "epochDay", + "columnName": "epochDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "habitId", + "epochDay" + ] + }, + "indices": [ + { + "name": "index_habit_numeric_entries_habitId", + "unique": false, + "columnNames": [ + "habitId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_habit_numeric_entries_habitId` ON `${TABLE_NAME}` (`habitId`)" + } + ], + "foreignKeys": [ + { + "table": "habits", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "habitId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b54bd27ed01315f4a10f6490cd7bf7ab')" + ] + } +} \ No newline at end of file diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt b/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt index e04acf67..29b58cf8 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt @@ -7,7 +7,6 @@ import com.markel.flowstate.core.data.local.HabitNumericEntryEntity import com.markel.flowstate.core.data.local.HabitWithEntries import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.HabitEntryFlat -import com.markel.flowstate.core.domain.HabitFrequency import com.markel.flowstate.core.domain.HabitNumericEntry import com.markel.flowstate.core.domain.HabitRepository import com.markel.flowstate.core.domain.HabitType @@ -84,13 +83,13 @@ class HabitRepositoryImpl @Inject constructor( name = name, iconName = iconName, colorArgb = colorArgb, - frequency = HabitFrequency.valueOf(frequency), createdAt = LocalDate.ofEpochDay(createdAt / 86400000), habitType = HabitType.valueOf(habitType), unit = unit, targetValue = targetValue, step = step, - position = position + position = position, + scheduledDays = scheduledDays ) private fun Habit.toEntity() = HabitEntity( @@ -98,13 +97,13 @@ class HabitRepositoryImpl @Inject constructor( name = name, iconName = iconName, colorArgb = colorArgb, - frequency = frequency.name, createdAt = createdAt.toEpochDay() * 86400000, habitType = habitType.name, unit = unit, targetValue = targetValue, step = step, - position = position + position = position, + scheduledDays = scheduledDays ) private fun HabitNumericEntryEntity.toDomain() = HabitNumericEntry( diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/backup/ExportSchema.kt b/core/data/src/main/java/com/markel/flowstate/core/data/backup/ExportSchema.kt index 8df0e4c7..d69af8b9 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/backup/ExportSchema.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/backup/ExportSchema.kt @@ -1,5 +1,6 @@ package com.markel.flowstate.core.data.backup +import com.markel.flowstate.core.data.local.HabitConverters import kotlinx.serialization.Serializable /** @@ -99,13 +100,13 @@ data class HabitSchema( val name: String, val iconName: String, val colorArgb: Int, - val frequency: String, val createdAt: Long, val habitType: String, val unit: String? = null, val targetValue: Float? = null, val step: Float, - val position: Int + val position: Int, + val scheduledDays: String = HabitConverters.ALL_DAYS ) @Serializable diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/backup/Mappers.kt b/core/data/src/main/java/com/markel/flowstate/core/data/backup/Mappers.kt index 6894cae7..58033790 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/backup/Mappers.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/backup/Mappers.kt @@ -2,6 +2,8 @@ package com.markel.flowstate.core.data.backup import com.markel.flowstate.core.data.local.* +private val habitConverters = HabitConverters() + // ── Task ────────────────────────────────────────────────────────────── fun TaskEntity.toSchema() = TaskSchema( @@ -121,13 +123,13 @@ fun HabitEntity.toSchema() = HabitSchema( name = name, iconName = iconName, colorArgb = colorArgb, - frequency = frequency, createdAt = createdAt, habitType = habitType, unit = unit, targetValue = targetValue, step = step, - position = position + position = position, + scheduledDays = habitConverters.fromScheduledDays(scheduledDays) ) fun HabitSchema.toEntity() = HabitEntity( @@ -135,13 +137,13 @@ fun HabitSchema.toEntity() = HabitEntity( name = name, iconName = iconName, colorArgb = colorArgb, - frequency = frequency, createdAt = createdAt, habitType = habitType, unit = unit, targetValue = targetValue, step = step, - position = position + position = position, + scheduledDays = habitConverters.toScheduledDays(scheduledDays) ) fun HabitEntryEntity.toSchema() = HabitEntrySchema( diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/di/DatabaseModule.kt b/core/data/src/main/java/com/markel/flowstate/core/data/di/DatabaseModule.kt index a7060f16..4a515383 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/di/DatabaseModule.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/di/DatabaseModule.kt @@ -32,7 +32,7 @@ object DatabaseModule { .addMigrations(FlowStateDatabase.MIGRATION_5_6, FlowStateDatabase.MIGRATION_6_7, FlowStateDatabase.MIGRATION_7_8, FlowStateDatabase.MIGRATION_8_9, FlowStateDatabase.MIGRATION_9_10, FlowStateDatabase.MIGRATION_10_11, FlowStateDatabase.MIGRATION_11_12, FlowStateDatabase.MIGRATION_12_13, FlowStateDatabase.MIGRATION_13_14, FlowStateDatabase.MIGRATION_14_15, FlowStateDatabase.MIGRATION_15_16, FlowStateDatabase.MIGRATION_16_17, - FlowStateDatabase.MIGRATION_17_18, FlowStateDatabase.MIGRATION_18_19) + FlowStateDatabase.MIGRATION_17_18, FlowStateDatabase.MIGRATION_18_19, FlowStateDatabase.MIGRATION_19_20) .build() } diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/local/FlowStateDatabase.kt b/core/data/src/main/java/com/markel/flowstate/core/data/local/FlowStateDatabase.kt index be4ef701..8cb40df7 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/local/FlowStateDatabase.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/local/FlowStateDatabase.kt @@ -3,6 +3,7 @@ package com.markel.flowstate.core.data.local import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @@ -13,9 +14,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase */ @Database( entities = [TaskEntity::class, SubTaskEntity::class, IdeaEntity::class, CheckListEntity::class, CheckListItemEntity::class, HabitEntity::class, HabitEntryEntity::class, HabitNumericEntryEntity::class, CategoryEntity::class], // List of all tables - version = 19, + version = 20, exportSchema = true ) +@TypeConverters(HabitConverters::class) abstract class FlowStateDatabase : RoomDatabase() { // Exposes our DAO so the rest of the app can use it @@ -253,5 +255,117 @@ abstract class FlowStateDatabase : RoomDatabase() { ) } } + + /** + * Replaces the unused frequency field with the concrete weekdays on + * which the habit is scheduled. Existing habits remain daily. + * + * The dependent entry tables are temporarily copied and recreated so + * the migration works on Android versions whose SQLite does not + * support DROP COLUMN, without losing entries through FK cascades. + */ + val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE habits_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL, + iconName TEXT NOT NULL, + colorArgb INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + habitType TEXT NOT NULL, + unit TEXT, + targetValue REAL, + step REAL NOT NULL, + position INTEGER NOT NULL, + scheduledDays TEXT NOT NULL DEFAULT '${HabitConverters.ALL_DAYS}' + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO habits_new ( + id, name, iconName, colorArgb, createdAt, habitType, + unit, targetValue, step, position, scheduledDays + ) + SELECT + id, name, iconName, colorArgb, createdAt, habitType, + unit, targetValue, step, position, '${HabitConverters.ALL_DAYS}' + FROM habits + """.trimIndent() + ) + + db.execSQL( + "CREATE TEMP TABLE habit_entries_19_backup AS " + + "SELECT id, habitId, completedAt FROM habit_entries" + ) + db.execSQL( + "CREATE TEMP TABLE habit_numeric_entries_19_backup AS " + + "SELECT habitId, epochDay, value FROM habit_numeric_entries" + ) + + db.execSQL("DROP TABLE habit_entries") + db.execSQL("DROP TABLE habit_numeric_entries") + db.execSQL("DROP TABLE habits") + db.execSQL("ALTER TABLE habits_new RENAME TO habits") + + db.execSQL( + """ + CREATE TABLE habit_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + habitId INTEGER NOT NULL, + completedAt INTEGER NOT NULL, + FOREIGN KEY(habitId) REFERENCES habits(id) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX index_habit_entries_habitId " + + "ON habit_entries (habitId)" + ) + db.execSQL( + """ + INSERT INTO habit_entries (id, habitId, completedAt) + SELECT id, habitId, completedAt + FROM habit_entries_19_backup + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE habit_numeric_entries ( + habitId INTEGER NOT NULL, + epochDay INTEGER NOT NULL, + value REAL NOT NULL, + PRIMARY KEY(habitId, epochDay), + FOREIGN KEY(habitId) REFERENCES habits(id) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX index_habit_numeric_entries_habitId " + + "ON habit_numeric_entries (habitId)" + ) + db.execSQL( + """ + INSERT INTO habit_numeric_entries (habitId, epochDay, value) + SELECT habitId, epochDay, value + FROM habit_numeric_entries_19_backup + """.trimIndent() + ) + + db.execSQL("DROP TABLE habit_entries_19_backup") + db.execSQL("DROP TABLE habit_numeric_entries_19_backup") + + db.execSQL("DELETE FROM sqlite_sequence WHERE name = 'habits'") + db.execSQL( + "INSERT INTO sqlite_sequence (name, seq) " + + "SELECT 'habits', COALESCE(MAX(id), 0) FROM habits" + ) + } + } } } \ No newline at end of file diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitConverters.kt b/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitConverters.kt new file mode 100644 index 00000000..0f82beac --- /dev/null +++ b/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitConverters.kt @@ -0,0 +1,30 @@ +package com.markel.flowstate.core.data.local + +import androidx.room.TypeConverter +import java.time.DayOfWeek + +/** + * Converts the weekdays selected for a habit to the textual representation + * stored by Room, for example: "MONDAY,WEDNESDAY,FRIDAY". + */ +class HabitConverters { + + @TypeConverter + fun fromScheduledDays(days: Set): String = + days + .sortedBy { it.value } + .joinToString(",") { it.name } + + @TypeConverter + fun toScheduledDays(value: String): Set = + if (value.isBlank()) { + emptySet() + } else { + value.split(",").mapTo(linkedSetOf()) { DayOfWeek.valueOf(it) } + } + + companion object { + const val ALL_DAYS = + "MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY" + } +} diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitEntity.kt b/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitEntity.kt index 6a191b4b..ea5a13e6 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitEntity.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/local/HabitEntity.kt @@ -1,9 +1,11 @@ package com.markel.flowstate.core.data.local +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import java.time.DayOfWeek @Entity(tableName = "habits") data class HabitEntity( @@ -12,13 +14,14 @@ data class HabitEntity( val name: String, val iconName: String = "self_improvement", val colorArgb: Int = 0xFF6650A4.toInt(), - val frequency: String = "DAILY", val createdAt: Long = System.currentTimeMillis(), val habitType: String = "BOOLEAN", val unit: String? = null, val targetValue: Float? = null, val step: Float = 1f, - val position: Int = 0 + val position: Int = 0, + @ColumnInfo(defaultValue = HabitConverters.ALL_DAYS) + val scheduledDays: Set = DayOfWeek.entries.toSet() ) @Entity( diff --git a/core/data/src/test/java/com/markel/flowstate/core/data/BackupRepositoryImplTest.kt b/core/data/src/test/java/com/markel/flowstate/core/data/BackupRepositoryImplTest.kt index 3f95ff34..e78b2f86 100644 --- a/core/data/src/test/java/com/markel/flowstate/core/data/BackupRepositoryImplTest.kt +++ b/core/data/src/test/java/com/markel/flowstate/core/data/BackupRepositoryImplTest.kt @@ -10,6 +10,7 @@ import com.markel.flowstate.core.data.local.CheckListDao import com.markel.flowstate.core.data.local.CheckListEntity import com.markel.flowstate.core.data.local.CheckListItemEntity import com.markel.flowstate.core.data.local.CheckListWithItems +import com.markel.flowstate.core.data.local.HabitConverters import com.markel.flowstate.core.data.local.HabitDao import com.markel.flowstate.core.data.local.HabitEntryEntity import com.markel.flowstate.core.data.local.HabitEntity @@ -79,7 +80,7 @@ class BackupRepositoryImplTest { private val sampleHabit = HabitEntity( id = 1, name = "Meditation", iconName = "self_improvement", - colorArgb = 0xFF6650A4.toInt(), frequency = "DAILY", + colorArgb = 0xFF6650A4.toInt(), createdAt = 1700000000000L, habitType = "BOOLEAN", unit = null, targetValue = null, step = 1f, position = 0 ) @@ -448,13 +449,13 @@ class BackupRepositoryImplTest { assertEquals(sampleHabit.name, habit.name) assertEquals(sampleHabit.iconName, habit.iconName) assertEquals(sampleHabit.colorArgb, habit.colorArgb) - assertEquals(sampleHabit.frequency, habit.frequency) assertEquals(sampleHabit.createdAt, habit.createdAt) assertEquals(sampleHabit.habitType, habit.habitType) assertEquals(sampleHabit.unit, habit.unit) assertEquals(sampleHabit.targetValue, habit.targetValue) assertEquals(sampleHabit.step, habit.step, 0.001f) assertEquals(sampleHabit.position, habit.position) + assertEquals(HabitConverters.ALL_DAYS, habit.scheduledDays) // -- Habit Entries -- assertEquals(1, exported.habitEntries.size) diff --git a/core/data/src/test/java/com/markel/flowstate/core/data/HabitConvertersTest.kt b/core/data/src/test/java/com/markel/flowstate/core/data/HabitConvertersTest.kt new file mode 100644 index 00000000..19880292 --- /dev/null +++ b/core/data/src/test/java/com/markel/flowstate/core/data/HabitConvertersTest.kt @@ -0,0 +1,46 @@ +package com.markel.flowstate.core.data + +import com.markel.flowstate.core.data.local.HabitConverters +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.DayOfWeek + +class HabitConvertersTest { + + private val converters = HabitConverters() + + @Test + fun fromScheduledDays_ordersDaysFromMondayToSunday() { + val days = setOf( + DayOfWeek.FRIDAY, + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY + ) + + assertEquals( + "MONDAY,WEDNESDAY,FRIDAY", + converters.fromScheduledDays(days) + ) + } + + @Test + fun scheduledDays_roundTrip_preservesSelection() { + val days = setOf( + DayOfWeek.TUESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.SUNDAY + ) + + val restored = converters.toScheduledDays( + converters.fromScheduledDays(days) + ) + + assertEquals(days, restored) + } + + @Test + fun emptySelection_roundTrip_isSupported() { + assertEquals(emptySet(), converters.toScheduledDays("")) + assertEquals("", converters.fromScheduledDays(emptySet())) + } +} diff --git a/core/data/src/test/java/com/markel/flowstate/core/data/MappersTest.kt b/core/data/src/test/java/com/markel/flowstate/core/data/MappersTest.kt index 668291f3..15a00f58 100644 --- a/core/data/src/test/java/com/markel/flowstate/core/data/MappersTest.kt +++ b/core/data/src/test/java/com/markel/flowstate/core/data/MappersTest.kt @@ -22,6 +22,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test +import java.time.DayOfWeek /** * Unit tests for the bidirectional mapper extension functions in [Mappers.kt]. @@ -417,13 +418,17 @@ class MappersTest { name = "Running", iconName = "directions_run", colorArgb = 0xFFE91E63.toInt(), - frequency = "DAILY", createdAt = 1700000000000L, habitType = "NUMERIC", unit = "km", targetValue = 5.0f, step = 0.5f, - position = 2 + position = 2, + scheduledDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.FRIDAY + ) ) val schema = entity.toSchema() @@ -432,12 +437,12 @@ class MappersTest { assertEquals(entity.name, schema.name) assertEquals(entity.iconName, schema.iconName) assertEquals(entity.colorArgb, schema.colorArgb) - assertEquals(entity.frequency, schema.frequency) assertEquals(entity.createdAt, schema.createdAt) assertEquals(entity.habitType, schema.habitType) assertEquals(entity.unit, schema.unit) assertEquals(entity.step, schema.step, 0.001f) assertEquals(entity.position, schema.position) + assertEquals("MONDAY,WEDNESDAY,FRIDAY", schema.scheduledDays) } @Test @@ -447,13 +452,13 @@ class MappersTest { name = "Reading", iconName = "menu_book", colorArgb = 0xFF4CAF50.toInt(), - frequency = "WEEKLY", createdAt = 1700099999999L, habitType = "BOOLEAN", unit = null, targetValue = null, step = 1f, - position = 0 + position = 0, + scheduledDays = "TUESDAY,THURSDAY" ) val entity = schema.toEntity() @@ -462,19 +467,19 @@ class MappersTest { assertEquals(schema.name, entity.name) assertEquals(schema.iconName, entity.iconName) assertEquals(schema.colorArgb, entity.colorArgb) - assertEquals(schema.frequency, entity.frequency) assertEquals(schema.createdAt, entity.createdAt) assertEquals(schema.habitType, entity.habitType) assertEquals(schema.unit, entity.unit) assertEquals(schema.step, entity.step, 0.001f) assertEquals(schema.position, entity.position) + assertEquals(setOf(DayOfWeek.TUESDAY, DayOfWeek.THURSDAY), entity.scheduledDays) } @Test fun habitEntity_roundTrip_withAllFieldsPopulated() { val original = HabitEntity( id = 10, name = "Yoga", iconName = "self_improvement", - colorArgb = 0xFF9C27B0.toInt(), frequency = "DAILY", + colorArgb = 0xFF9C27B0.toInt(), createdAt = 5000L, habitType = "NUMERIC", unit = "min", targetValue = 30f, step = 5f, position = 8 ) @@ -486,7 +491,7 @@ class MappersTest { fun habitEntity_roundTrip_withNullablesNull_preservesAllFields() { val original = HabitEntity( id = 11, name = "Meditate", iconName = "spa", - colorArgb = 0xFF6650A4.toInt(), frequency = "DAILY", + colorArgb = 0xFF6650A4.toInt(), createdAt = 6000L, habitType = "BOOLEAN", unit = null, targetValue = null, step = 1f, position = 0 ) @@ -500,7 +505,7 @@ class MappersTest { fun habitSchema_roundTrip_preservesAllFields() { val original = HabitSchema( id = 30, name = "Push-ups", iconName = "fitness_center", - colorArgb = 0xFFF44336.toInt(), frequency = "DAILY", + colorArgb = 0xFFF44336.toInt(), createdAt = 7777L, habitType = "NUMERIC", unit = "reps", targetValue = 50f, step = 10f, position = 3 ) @@ -633,7 +638,7 @@ class MappersTest { // Step is Float — verify zero step doesn't get lost val original = HabitEntity( id = 0, name = "Zero step", iconName = "", - colorArgb = 0, frequency = "", + colorArgb = 0, createdAt = 0L, habitType = "", unit = "", targetValue = 0f, step = 0f, position = 0 ) @@ -686,7 +691,7 @@ class MappersTest { fun habitEntity_fullRoundTrip_viaJson_preservesNullableFields() { val original = HabitEntity( id = 1, name = "Full RT Habit", iconName = "spa", - colorArgb = 0xFF6650A4.toInt(), frequency = "DAILY", + colorArgb = 0xFF6650A4.toInt(), createdAt = 1700000000000L, habitType = "BOOLEAN", unit = null, targetValue = null, step = 1f, position = 0 ) @@ -704,7 +709,7 @@ class MappersTest { fun habitEntity_fullRoundTrip_viaJson_withNumericFields_preservesAllFields() { val original = HabitEntity( id = 2, name = "Water intake", iconName = "water_drop", - colorArgb = 0xFF2196F3.toInt(), frequency = "DAILY", + colorArgb = 0xFF2196F3.toInt(), createdAt = 1700000000000L, habitType = "NUMERIC", unit = "ml", targetValue = 2000f, step = 250f, position = 1 ) diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt index 3d43b5fd..c639056b 100644 --- a/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt @@ -1,8 +1,8 @@ package com.markel.flowstate.core.domain +import java.time.DayOfWeek import java.time.LocalDate -enum class HabitFrequency { DAILY, WEEKLY } enum class HabitType { BOOLEAN, NUMERIC } data class Habit( @@ -10,13 +10,13 @@ data class Habit( val name: String, val iconName: String = "self_improvement", val colorArgb: Int = 0xFF6650A4.toInt(), - val frequency: HabitFrequency = HabitFrequency.DAILY, val createdAt: LocalDate = LocalDate.now(), val habitType: HabitType = HabitType.BOOLEAN, val unit: String? = null, val targetValue: Float? = null, val step: Float = 1f, - val position: Int = 0 + val position: Int = 0, + val scheduledDays: Set = DayOfWeek.entries.toSet() ) data class HabitWithStatus( From bc7177a1fe7dea337979aa52ea64ca468140da12 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Fri, 21 Aug 2026 18:11:36 +0200 Subject: [PATCH 02/14] feat: phase 2 of allowing different scheduled days for the habits Just the UI update to allow this, some minor logical implementations to save the selected scheduled days, including editions. --- .../flowstate/feature/habits/HabitScreen.kt | 41 ++++--- .../feature/habits/HabitViewModel.kt | 17 ++- .../habits/components/AddHabitSheet.kt | 106 ++++++++++++++++-- .../feature/habits/components/HabitCard.kt | 12 +- .../habits/components/NumericHabitCard.kt | 15 ++- .../habits/src/main/res/values-en/strings.xml | 2 + .../habits/src/main/res/values-es/strings.xml | 2 + .../habits/src/main/res/values/strings.xml | 2 + .../feature/habits/HabitViewModelTest.kt | 76 ++++++++++++- 9 files changed, 235 insertions(+), 38 deletions(-) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt index 1fde0ef5..9868b15e 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt @@ -132,12 +132,13 @@ fun HabitScreen( habitWithStatus.habit ) }, - onEdit = { name, icon, colorArgb -> + onEdit = { name, icon, colorArgb, scheduledDays -> viewModel.editHabit( - habitWithStatus.habit, - name, - icon, - colorArgb + habit = habitWithStatus.habit, + newName = name, + newIcon = icon, + newColorArgb = colorArgb, + newScheduledDays = scheduledDays ) }, onNavigateToDetail = { @@ -188,15 +189,16 @@ fun HabitScreen( habitWithStatus.habit ) }, - onEdit = { name, icon, colorArgb, unit, targetValue, step -> + onEdit = { name, icon, colorArgb, unit, targetValue, step, scheduledDays -> viewModel.editHabit( - habitWithStatus.habit, - name, - icon, - colorArgb, - unit, - targetValue, - step + habit = habitWithStatus.habit, + newName = name, + newIcon = icon, + newColorArgb = colorArgb, + newUnit = unit, + newTargetValue = targetValue, + newStep = step, + newScheduledDays = scheduledDays ) }, onNavigateToDetail = { @@ -218,8 +220,17 @@ fun HabitScreen( AddHabitSheet( initialHabitType = addSheetType, onDismiss = { viewModel.hideAddDialog() }, - onConfirm = { name, icon, color, habitType, unit, targetValue, step -> - viewModel.addHabit(name, icon, color, habitType, unit, targetValue, step) + onConfirm = { name, icon, color, habitType, unit, targetValue, step, scheduledDays -> + viewModel.addHabit( + name = name, + iconName = icon, + colorArgb = color, + habitType = habitType, + unit = unit, + targetValue = targetValue, + step = step, + scheduledDays = scheduledDays + ) } ) } diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt index b92097dd..6c33d6bf 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import java.time.DayOfWeek import java.time.LocalDate import javax.inject.Inject @@ -141,9 +142,10 @@ class HabitViewModel @Inject constructor( colorArgb: Int, habitType: HabitType = HabitType.BOOLEAN, unit: String? = null, targetValue: Float? = null, - step: Float = 1f) + step: Float = 1f, + scheduledDays: Set = DayOfWeek.entries.toSet()) { - if (name.isBlank()) return + if (name.isBlank() || scheduledDays.isEmpty()) return viewModelScope.launch { insertHabit( Habit( @@ -153,7 +155,8 @@ class HabitViewModel @Inject constructor( habitType = habitType, unit = unit, targetValue = targetValue, - step = step + step = step, + scheduledDays = scheduledDays ) ) _showAddDialog.value = false @@ -170,9 +173,10 @@ class HabitViewModel @Inject constructor( newColorArgb: Int, newUnit: String? = null, newTargetValue: Float? = null, - newStep: Float? = null + newStep: Float? = null, + newScheduledDays: Set = habit.scheduledDays ) { - if (newName.isBlank()) return + if (newName.isBlank() || newScheduledDays.isEmpty()) return viewModelScope.launch { updateHabit( habit.copy( @@ -181,7 +185,8 @@ class HabitViewModel @Inject constructor( colorArgb = newColorArgb, unit = newUnit, targetValue = newTargetValue, - step = newStep ?: habit.step + step = newStep ?: habit.step, + scheduledDays = newScheduledDays ) ) } diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt index c2bf7b86..84fccd0f 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -53,11 +54,17 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.markel.flowstate.core.domain.HabitType +import java.time.DayOfWeek +import java.time.format.TextStyle import com.markel.flowstate.feature.habits.R import com.markel.flowstate.feature.habits.util.formatFloat import com.markel.flowstate.core.designsystem.R as DesignR @@ -88,7 +95,8 @@ fun AddHabitSheet( habitType: HabitType, unit: String?, targetValue: Float?, - step: Float + step: Float, + scheduledDays: Set ) -> Unit, initialName: String = "", initialIcon: String = "none", @@ -96,7 +104,8 @@ fun AddHabitSheet( initialHabitType: HabitType = HabitType.BOOLEAN, initialUnit: String? = null, initialTargetValue: Float? = null, - initialStep: Float = 1f + initialStep: Float = 1f, + initialScheduledDays: Set = DayOfWeek.entries.toSet() ) { val isEditMode = initialName.isNotEmpty() || initialColor != null var name by remember { mutableStateOf(initialName) } @@ -108,6 +117,9 @@ fun AddHabitSheet( var unit by remember { mutableStateOf(initialUnit ?: "") } var targetValueText by remember { mutableStateOf(initialTargetValue?.let { formatFloat(it) } ?: "") } var stepText by remember { mutableStateOf(formatFloat(initialStep)) } + var scheduledDays by remember(initialScheduledDays) { + mutableStateOf(initialScheduledDays.toSet()) + } val parsedTarget = targetValueText.toFloatOrNull() val parsedStep = stepText.toFloatOrNull() @@ -237,6 +249,73 @@ fun AddHabitSheet( } } + // ── Schedule ───────────────────────────────────────────────── + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.habit_schedule_title), + style = MaterialTheme.typography.labelLarge + ) + Text( + text = stringResource(R.string.habit_schedule_at_least_one), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + val locale = LocalLocale.current.platformLocale + DayOfWeek.entries.forEach { day -> + val isSelected = day in scheduledDays + val canToggle = !isSelected || scheduledDays.size > 1 + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .height(42.dp) + .clip(CircleShape) + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest + ) + .border( + width = 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape + ) + .semantics { + contentDescription = day.getDisplayName(TextStyle.FULL, locale) + } + .toggleable( + value = isSelected, + enabled = canToggle, + role = Role.Checkbox + ) { checked -> + scheduledDays = if (checked) { + scheduledDays + day + } else { + scheduledDays - day + } + } + ) { + Text( + text = day + .getDisplayName(TextStyle.NARROW, locale) + .uppercase(locale), + style = MaterialTheme.typography.labelLarge, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } + } + // ── Icon picker ────────────────────────────────────────────── Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.icon), style = MaterialTheme.typography.labelLarge) @@ -300,11 +379,13 @@ fun AddHabitSheet( habitType, if (habitType == HabitType.NUMERIC && unit.isNotBlank()) unit else null, if (habitType == HabitType.NUMERIC) target else null, - if (habitType == HabitType.NUMERIC && stepText.isNotBlank()) step else 1f + if (habitType == HabitType.NUMERIC && stepText.isNotBlank()) step else 1f, + scheduledDays ) onDismiss() }, - enabled = name.isNotBlank() && !isTargetInvalid && !isStepInvalid, + enabled = name.isNotBlank() && scheduledDays.isNotEmpty() && + !isTargetInvalid && !isStepInvalid, shapes = ButtonDefaults.shapes(), modifier = Modifier.weight(1f) ) { @@ -326,21 +407,30 @@ fun AddHabitSheet( @Composable fun AddHabitSheet( onDismiss: () -> Unit, - onConfirm: (name: String, icon: String, colorArgb: Int) -> Unit, + onConfirm: ( + name: String, + icon: String, + colorArgb: Int, + scheduledDays: Set + ) -> Unit, initialName: String = "", initialIcon: String = "none", - initialColor: Color? = null + initialColor: Color? = null, + initialScheduledDays: Set = DayOfWeek.entries.toSet() ) { AddHabitSheet( onDismiss = onDismiss, - onConfirm = { name, icon, colorArgb, _, _, _, _ -> onConfirm(name, icon, colorArgb) }, + onConfirm = { name, icon, colorArgb, _, _, _, _, scheduledDays -> + onConfirm(name, icon, colorArgb, scheduledDays) + }, initialName = initialName, initialIcon = initialIcon, initialColor = initialColor, initialHabitType = HabitType.BOOLEAN, initialUnit = null, initialTargetValue = null, - initialStep = 1f + initialStep = 1f, + initialScheduledDays = initialScheduledDays ) } diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt index e346cc50..d077299c 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt @@ -38,7 +38,12 @@ fun HabitCard( weekEntries: Set, onToggleDay: (LocalDate) -> Unit, onDelete: () -> Unit, - onEdit: (name: String, icon:String, colorArgb: Int) -> Unit, + onEdit: ( + name: String, + icon: String, + colorArgb: Int, + scheduledDays: Set + ) -> Unit, onNavigateToDetail: (() -> Unit)? = null ) { val habit = habitWithStatus.habit @@ -94,9 +99,10 @@ fun HabitCard( initialName = habit.name, initialIcon = habit.iconName, initialColor = habitColor, + initialScheduledDays = habit.scheduledDays, onDismiss = { showEditDialog = false }, - onConfirm = { name, icon, colorArgb -> - onEdit(name, icon, colorArgb) + onConfirm = { name, icon, colorArgb, scheduledDays -> + onEdit(name, icon, colorArgb, scheduledDays) showEditDialog = false } ) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt index 220ab124..0776f954 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt @@ -44,7 +44,15 @@ fun NumericHabitCard( onDecrementToday: () -> Unit, onSetValue: (LocalDate, Float?) -> Unit, onDelete: () -> Unit, - onEdit: (name: String, icon: String, colorArgb: Int, unit: String?, targetValue: Float?, step: Float?) -> Unit, + onEdit: ( + name: String, + icon: String, + colorArgb: Int, + unit: String?, + targetValue: Float?, + step: Float?, + scheduledDays: Set + ) -> Unit, onNavigateToDetail: (() -> Unit)? = null ) { val habit = habitWithStatus.habit @@ -140,9 +148,10 @@ fun NumericHabitCard( initialUnit = habit.unit, initialTargetValue = habit.targetValue, initialStep = habit.step, + initialScheduledDays = habit.scheduledDays, onDismiss = { showEditDialog = false }, - onConfirm = { name, icon, colorArgb, _, unit, target, step -> - onEdit(name, icon, colorArgb, unit, target, step) + onConfirm = { name, icon, colorArgb, _, unit, target, step, scheduledDays -> + onEdit(name, icon, colorArgb, unit, target, step, scheduledDays) showEditDialog = false } ) diff --git a/feature/habits/src/main/res/values-en/strings.xml b/feature/habits/src/main/res/values-en/strings.xml index 550ba041..dd149512 100644 --- a/feature/habits/src/main/res/values-en/strings.xml +++ b/feature/habits/src/main/res/values-en/strings.xml @@ -7,6 +7,8 @@ Goal reached! Name Color + Scheduled days + Select at least one day Create Cancel Habits diff --git a/feature/habits/src/main/res/values-es/strings.xml b/feature/habits/src/main/res/values-es/strings.xml index 1ffc8c53..9d2ca2a8 100644 --- a/feature/habits/src/main/res/values-es/strings.xml +++ b/feature/habits/src/main/res/values-es/strings.xml @@ -7,6 +7,8 @@ ¡Objetivo cumplido! Nombre Color + Días programados + Selecciona al menos un día Crear Cancelar Hábitos diff --git a/feature/habits/src/main/res/values/strings.xml b/feature/habits/src/main/res/values/strings.xml index 5d273b91..111ab839 100644 --- a/feature/habits/src/main/res/values/strings.xml +++ b/feature/habits/src/main/res/values/strings.xml @@ -6,6 +6,8 @@ Goal reached! Name Color + Scheduled days + Select at least one day Create Cancel Habits diff --git a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt index 4fa4c95e..6b8a3454 100644 --- a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt +++ b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt @@ -29,6 +29,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test +import java.time.DayOfWeek import java.time.LocalDate class HabitViewModelTest { @@ -260,11 +261,57 @@ class HabitViewModelTest { habit.name == "Read" && habit.iconName == "book_icon" && habit.colorArgb == 0xFF0000FF.toInt() && - habit.habitType == HabitType.BOOLEAN + habit.habitType == HabitType.BOOLEAN && + habit.scheduledDays == DayOfWeek.entries.toSet() }) } } + @Test + fun addHabit_withSelectedDays_persistsSelection() = runTest { + // GIVEN + coEvery { getHabitsWithStatus() } returns flowOf(emptyList()) + coEvery { getAllBooleanEntries() } returns flowOf(emptyList()) + viewModel = buildViewModel() + val selectedDays = setOf( + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.FRIDAY + ) + + // WHEN + viewModel.addHabit( + name = "Exercise", + iconName = "fitness_center", + colorArgb = 0, + scheduledDays = selectedDays + ) + + // THEN + coVerify { + insertHabit(match { it.scheduledDays == selectedDays }) + } + } + + @Test + fun addHabit_withNoScheduledDays_doesNotCallInsertHabitUseCase() = runTest { + // GIVEN + coEvery { getHabitsWithStatus() } returns flowOf(emptyList()) + coEvery { getAllBooleanEntries() } returns flowOf(emptyList()) + viewModel = buildViewModel() + + // WHEN + viewModel.addHabit( + name = "Exercise", + iconName = "fitness_center", + colorArgb = 0, + scheduledDays = emptySet() + ) + + // THEN + coVerify(exactly = 0) { insertHabit(any()) } + } + @Test fun addHabit_withBlankName_doesNotCallRepository() = runTest { // GIVEN @@ -309,6 +356,7 @@ class HabitViewModelTest { coEvery { getAllBooleanEntries() } returns flowOf(emptyList()) viewModel = buildViewModel() val oldHabit = habit(id = 1, name = "Old Name") + val newScheduledDays = setOf(DayOfWeek.TUESDAY, DayOfWeek.THURSDAY) // WHEN viewModel.editHabit( @@ -318,7 +366,8 @@ class HabitViewModelTest { newColorArgb = 0xFF00FF00.toInt(), newUnit = "Pages", newTargetValue = 20f, - newStep = 5f + newStep = 5f, + newScheduledDays = newScheduledDays ) // THEN @@ -330,7 +379,8 @@ class HabitViewModelTest { habit.colorArgb == 0xFF00FF00.toInt() && habit.unit == "Pages" && habit.targetValue == 20f && - habit.step == 5f + habit.step == 5f && + habit.scheduledDays == newScheduledDays }) } } @@ -355,6 +405,26 @@ class HabitViewModelTest { coVerify(exactly = 0) { updateHabit(any()) } } + @Test + fun editHabit_withNoScheduledDays_doesNotCallUpdateHabitUseCase() = runTest { + // GIVEN + coEvery { getHabitsWithStatus() } returns flowOf(emptyList()) + coEvery { getAllBooleanEntries() } returns flowOf(emptyList()) + viewModel = buildViewModel() + + // WHEN + viewModel.editHabit( + habit = habit(id = 1), + newName = "Exercise", + newIcon = "fitness_center", + newColorArgb = 0, + newScheduledDays = emptySet() + ) + + // THEN + coVerify(exactly = 0) { updateHabit(any()) } + } + // ── deleteHabit ─────────────────────────────────────────────────────────── @Test From 4fb4e29299acabd9b8b51ee09ec1e3904191a914 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Fri, 21 Aug 2026 23:19:53 +0200 Subject: [PATCH 03/14] feat: phase 3 of allowing different scheduled days for the habits - The top progress bar only counts habits scheduled for today. - Unscheduled habits for today remain visible for viewing and editing. - The text "Not scheduled for today" is displayed. - The main button is disabled when the habit is not scheduled for today. - Unscheduled days appear dimmed in the calendars. - Unscheduled days cannot be selected or completed. - Numeric habit controls are disabled on unscheduled days. - Historical entries are not deleted. - The repository prevents saving boolean or numeric entries on unscheduled dates, even if attempted outside the UI. - The widget also verifies the schedule before modifying the habit. --- .../core/data/HabitRepositoryImpl.kt | 10 ++- .../core/data/HabitRepositoryImplTest.kt | 77 +++++++++++++++++++ .../com/markel/flowstate/core/domain/Habit.kt | 2 + .../core/domain/HabitScheduleTest.kt | 32 ++++++++ .../core/widgets/ToggleHabitAction.kt | 6 +- .../feature/habits/HabitViewModel.kt | 9 ++- .../feature/habits/components/HabitCard.kt | 39 ++++++---- .../habits/components/MorphingCheckButton.kt | 5 +- .../habits/components/NumericHabitCard.kt | 29 ++++--- .../habits/components/NumericWeekBar.kt | 21 +++-- .../habits/src/main/res/values-en/strings.xml | 1 + .../habits/src/main/res/values-es/strings.xml | 1 + .../habits/src/main/res/values/strings.xml | 1 + .../feature/habits/HabitViewModelTest.kt | 65 +++++++++++++++- 14 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 core/data/src/test/java/com/markel/flowstate/core/data/HabitRepositoryImplTest.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/HabitScheduleTest.kt diff --git a/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt b/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt index 29b58cf8..e63b82e1 100644 --- a/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt +++ b/core/data/src/main/java/com/markel/flowstate/core/data/HabitRepositoryImpl.kt @@ -42,8 +42,11 @@ class HabitRepositoryImpl @Inject constructor( override suspend fun deleteHabit(habit: Habit) = dao.deleteHabit(habit.toEntity()) - override suspend fun toggleEntry(habitId: Int, date: LocalDate) = + override suspend fun toggleEntry(habitId: Int, date: LocalDate) { + val habit = dao.getHabitById(habitId) ?: return + if (date.dayOfWeek !in habit.scheduledDays) return dao.toggleEntry(habitId, date.toEpochDay()) + } override fun getAllEntries(): Flow> = // boolean habits only dao.getAllEntries().map { list -> @@ -60,7 +63,9 @@ class HabitRepositoryImpl @Inject constructor( entries.map { it.toDomain() } } - override suspend fun logNumericEntry(habitId: Int, date: LocalDate, value: Float) = + override suspend fun logNumericEntry(habitId: Int, date: LocalDate, value: Float) { + val habit = dao.getHabitById(habitId) ?: return + if (date.dayOfWeek !in habit.scheduledDays) return dao.upsertNumericEntry( HabitNumericEntryEntity( habitId = habitId, @@ -68,6 +73,7 @@ class HabitRepositoryImpl @Inject constructor( value = value ) ) + } override suspend fun deleteNumericEntry(habitId: Int, date: LocalDate) = dao.deleteNumericEntry(habitId, date.toEpochDay()) diff --git a/core/data/src/test/java/com/markel/flowstate/core/data/HabitRepositoryImplTest.kt b/core/data/src/test/java/com/markel/flowstate/core/data/HabitRepositoryImplTest.kt new file mode 100644 index 00000000..c3707a05 --- /dev/null +++ b/core/data/src/test/java/com/markel/flowstate/core/data/HabitRepositoryImplTest.kt @@ -0,0 +1,77 @@ +package com.markel.flowstate.core.data + +import com.markel.flowstate.core.data.local.HabitDao +import com.markel.flowstate.core.data.local.HabitEntity +import com.markel.flowstate.core.data.local.HabitNumericEntryEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +class HabitRepositoryImplTest { + + private val dao: HabitDao = mockk(relaxed = true) + private val repository = HabitRepositoryImpl(dao) + private val monday = LocalDate.of(2026, 8, 17) + + @Test + fun toggleEntry_onScheduledDay_togglesDaoEntry() = runTest { + coEvery { dao.getHabitById(1) } returns habitEntity( + scheduledDays = setOf(DayOfWeek.MONDAY) + ) + + repository.toggleEntry(1, monday) + + coVerify { dao.toggleEntry(1, monday.toEpochDay()) } + } + + @Test + fun toggleEntry_onUnscheduledDay_doesNotToggleDaoEntry() = runTest { + coEvery { dao.getHabitById(1) } returns habitEntity( + scheduledDays = setOf(DayOfWeek.WEDNESDAY) + ) + + repository.toggleEntry(1, monday) + + coVerify(exactly = 0) { dao.toggleEntry(any(), any()) } + } + + @Test + fun logNumericEntry_onScheduledDay_upsertsEntry() = runTest { + coEvery { dao.getHabitById(1) } returns habitEntity( + scheduledDays = setOf(DayOfWeek.MONDAY) + ) + + repository.logNumericEntry(1, monday, 5f) + + coVerify { + dao.upsertNumericEntry( + HabitNumericEntryEntity( + habitId = 1, + epochDay = monday.toEpochDay(), + value = 5f + ) + ) + } + } + + @Test + fun logNumericEntry_onUnscheduledDay_doesNotUpsertEntry() = runTest { + coEvery { dao.getHabitById(1) } returns habitEntity( + scheduledDays = setOf(DayOfWeek.WEDNESDAY) + ) + + repository.logNumericEntry(1, monday, 5f) + + coVerify(exactly = 0) { dao.upsertNumericEntry(any()) } + } + + private fun habitEntity(scheduledDays: Set) = HabitEntity( + id = 1, + name = "Exercise", + scheduledDays = scheduledDays + ) +} diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt index c639056b..9e92c49f 100644 --- a/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt @@ -19,6 +19,8 @@ data class Habit( val scheduledDays: Set = DayOfWeek.entries.toSet() ) +fun Habit.isScheduledFor(date: LocalDate): Boolean = date.dayOfWeek in scheduledDays + data class HabitWithStatus( val habit: Habit, val isCompletedToday: Boolean, diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitScheduleTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitScheduleTest.kt new file mode 100644 index 00000000..842f7397 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitScheduleTest.kt @@ -0,0 +1,32 @@ +package com.markel.flowstate.core.domain + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +class HabitScheduleTest { + + private val monday = LocalDate.of(2026, 8, 17) + + @Test + fun isScheduledFor_returnsTrueForSelectedWeekday() { + val habit = Habit( + name = "Exercise", + scheduledDays = setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY) + ) + + assertTrue(habit.isScheduledFor(monday)) + } + + @Test + fun isScheduledFor_returnsFalseForUnselectedWeekday() { + val habit = Habit( + name = "Exercise", + scheduledDays = setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY) + ) + + assertFalse(habit.isScheduledFor(monday.plusDays(1))) + } +} diff --git a/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt b/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt index d9c97892..19cf01a1 100644 --- a/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt +++ b/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt @@ -8,8 +8,8 @@ import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition +import com.markel.flowstate.core.domain.isScheduledFor import dagger.hilt.android.EntryPointAccessors -import kotlinx.coroutines.flow.first import java.time.LocalDate class ToggleHabitAction : ActionCallback { @@ -32,6 +32,8 @@ class ToggleHabitAction : ActionCallback { ) val repository = entryPoint.habitRepository() val habit = repository.getHabitById(habitId) ?: return + val today = LocalDate.now() + if (!habit.isScheduledFor(today)) return // For the numeric habits (should be impossible, but in any case) open the app if (habit.habitType.name == "NUMERIC") { @@ -44,7 +46,7 @@ class ToggleHabitAction : ActionCallback { return } // Boolean habit : Toggle and persist in the DB - repository.toggleEntry(habitId, LocalDate.now()) + repository.toggleEntry(habitId, today) // Force redraw of the widget HabitWidget().update(context, glanceId) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt index 6c33d6bf..014e9439 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.HabitRepository import com.markel.flowstate.core.domain.HabitType +import com.markel.flowstate.core.domain.isScheduledFor import com.markel.flowstate.core.domain.usecase.habits.DecrementNumericValueUseCase import com.markel.flowstate.core.domain.usecase.habits.DeleteHabitUseCase import com.markel.flowstate.core.domain.usecase.habits.DeleteNumericEntryUseCase @@ -62,15 +63,17 @@ class HabitViewModel @Inject constructor( .mapValues { it.value.toSet() } val numericEntriesByHabit = allNumericEntries.groupBy { it.habitId } + val today = LocalDate.now() + val habitsDueToday = habits.filter { it.habit.isScheduledFor(today) } HabitUiState.Success( habits = habits, weekEntriesByHabit = weekEntriesByHabit, numericEntriesByHabit = numericEntriesByHabit, showAddDialog = showDialog, - completedToday = habits.count { it.isCompletedToday }, - totalHabits = habits.size, - motivationalMessageIndex = LocalDate.now().dayOfYear % 7 + completedToday = habitsDueToday.count { it.isCompletedToday }, + totalHabits = habitsDueToday.size, + motivationalMessageIndex = today.dayOfYear % 7 ) }.collect {newState -> _uiState.value = newState diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt index d077299c..93941d9b 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.sp import com.kizitonwose.calendar.compose.WeekCalendar import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState import com.markel.flowstate.core.domain.HabitWithStatus +import com.markel.flowstate.core.domain.isScheduledFor import com.markel.flowstate.feature.habits.R import java.time.DayOfWeek import java.time.LocalDate @@ -49,7 +50,8 @@ fun HabitCard( val habit = habitWithStatus.habit val habitColor = Color(habit.colorArgb) val today = LocalDate.now() - val isCompletedToday = today.toEpochDay() in weekEntries + val isScheduledToday = habit.isScheduledFor(today) + val isCompletedToday = isScheduledToday && today.toEpochDay() in weekEntries // State for the WeekCalendar — starts in the current week, // can scroll back to the beginning of the habit @@ -134,17 +136,25 @@ fun HabitCard( isCompleted = isCompletedToday, color = habitColor, iconName = habit.iconName, - onClick = { onToggleDay(today) } - ) - Text( - text = habit.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - modifier = Modifier - .weight(1f) - .basicMarquee(repeatDelayMillis = 3500) + onClick = { onToggleDay(today) }, + enabled = isScheduledToday ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = habit.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + modifier = Modifier.basicMarquee(repeatDelayMillis = 3500) + ) + if (!isScheduledToday) { + Text( + text = stringResource(R.string.habit_not_scheduled_today), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } Box { IconButton(onClick = { menuExpanded = true }) { Text( @@ -188,6 +198,7 @@ fun HabitCard( val isDone = date.toEpochDay() in weekEntries val isFuture = date.isAfter(today) val isToday = date == today + val isScheduled = habit.isScheduledFor(date) // Connection with adjacent days val donePrev = date.minusDays(1).toEpochDay() in weekEntries @@ -229,7 +240,7 @@ fun HabitCard( .clip(animatedShape) .background(bgColor) .then( - if (!isFuture) Modifier.clickable( + if (!isFuture && isScheduled) Modifier.clickable( role = Role.Button, onClick = { onToggleDay(date) } ) else Modifier @@ -247,6 +258,7 @@ fun HabitCard( color = when { isDone && !isFuture -> Color.White isFuture -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + !isScheduled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) isToday -> MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onSurfaceVariant } @@ -259,7 +271,8 @@ fun HabitCard( fontSize = 10.sp, color = when { isDone && !isFuture -> Color.White.copy(alpha = 0.8f) - isFuture -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + isFuture || !isScheduled -> + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) } ) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/MorphingCheckButton.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/MorphingCheckButton.kt index 1b1cb8e8..d96301ba 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/MorphingCheckButton.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/MorphingCheckButton.kt @@ -37,7 +37,8 @@ fun MorphingCheckButton( iconName: String, color: Color, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { // Animation states val morphProgress by animateFloatAsState( @@ -88,9 +89,11 @@ fun MorphingCheckButton( rotationZ = rotation scaleX = scale scaleY = scale + alpha = if (enabled) 1f else 0.4f } .background(color, shape) .clickable( + enabled = enabled, interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt index 0776f954..de42e15c 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt @@ -32,6 +32,7 @@ import java.time.LocalDate import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.times import com.markel.flowstate.core.domain.HabitNumericEntry +import com.markel.flowstate.core.domain.isScheduledFor import com.markel.flowstate.feature.habits.util.formatFloat import java.time.temporal.ChronoUnit import kotlinx.coroutines.launch @@ -89,6 +90,8 @@ fun NumericHabitCard( val selectedDayIndex = remember(selectedDate, weekStart) { ChronoUnit.DAYS.between(weekStart, selectedDate).toInt().coerceIn(0, 6) } + val isScheduledToday = habit.isScheduledFor(today) + val selectedDateIsScheduled = habit.isScheduledFor(selectedDate) val selectedValue = if (selectedDate.isAfter(today)) { null @@ -97,7 +100,7 @@ fun NumericHabitCard( } ?: 0f val targetValue = habit.targetValue - val isCompletedToday = habitWithStatus.isCompletedToday + val isCompletedToday = isScheduledToday && habitWithStatus.isCompletedToday val surfaceColor = MaterialTheme.colorScheme.surfaceContainer val cardBg by animateColorAsState( @@ -158,7 +161,7 @@ fun NumericHabitCard( } // Input sheet (goal-ring + hold-to-repeat steppers) - if (showInputDialog) { + if (showInputDialog && selectedDateIsScheduled) { val currentValueForDialog = currentWeekValues.getOrNull(selectedDayIndex) NumericInputSheet( habitName = habit.name, @@ -201,7 +204,8 @@ fun NumericHabitCard( isCompleted = isCompletedToday, color = habitColor, iconName = habit.iconName, - onClick = { } // Nothing to do here yet, only used as an indicator + onClick = { }, // Only used as an indicator for numeric habits + enabled = isScheduledToday ) Column(modifier = Modifier.weight(1f)) { @@ -223,6 +227,13 @@ fun NumericHabitCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + if (!isScheduledToday) { + Text( + text = stringResource(R.string.habit_not_scheduled_today), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } Box { @@ -295,6 +306,7 @@ fun NumericHabitCard( val date = weekStart.plusDays(index.toLong()) val isFuture = date.isAfter(today) val isToday = date == today + val isScheduled = habit.isScheduledFor(date) val isSelected = date == selectedDate NumericWeekBar( @@ -305,12 +317,9 @@ fun NumericHabitCard( date = date, isToday = isToday, isFuture = isFuture, + isScheduled = isScheduled, isSelected = isSelected, - onClick = { - if (!isFuture) { - selectedDate = date - } - }, + onClick = { selectedDate = date }, modifier = Modifier.weight(1f) ) } @@ -327,6 +336,7 @@ fun NumericHabitCard( // Actual value (clickable to edit) Surface( onClick = { showInputDialog = true }, + enabled = selectedDateIsScheduled, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier @@ -394,7 +404,7 @@ fun NumericHabitCard( onSetValue(selectedDate, if (newVal > 0f) newVal else null) } }, - enabled = selectedValue > 0, + enabled = selectedDateIsScheduled && selectedValue > 0, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = habitColor.copy(alpha = 0.60f), contentColor = habitColor @@ -445,6 +455,7 @@ fun NumericHabitCard( onSetValue(selectedDate, newVal) } }, + enabled = selectedDateIsScheduled, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = habitColor.copy(alpha = 0.60f), contentColor = habitColor diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericWeekBar.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericWeekBar.kt index 42b3d9b3..f4b987f6 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericWeekBar.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericWeekBar.kt @@ -38,6 +38,7 @@ fun NumericWeekBar( date: LocalDate, isToday: Boolean, isFuture: Boolean, + isScheduled: Boolean, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier @@ -66,6 +67,8 @@ fun NumericWeekBar( val barColor by animateColorAsState( targetValue = when { isFuture -> MaterialTheme.colorScheme.surfaceContainerHigh + !isScheduled && value != null && value > 0f -> color.copy(alpha = 0.2f) + !isScheduled -> MaterialTheme.colorScheme.surfaceContainerHigh value == null || value == 0f -> MaterialTheme.colorScheme.surfaceContainerHigh targetValue == null && value > 0 -> color targetValue != null && value >= targetValue -> color @@ -85,7 +88,10 @@ fun NumericWeekBar( } Column( - modifier = modifier.clickable(enabled = !isFuture, onClick = onClick), + modifier = modifier.clickable( + enabled = !isFuture && isScheduled, + onClick = onClick + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom ) { @@ -110,7 +116,7 @@ fun NumericWeekBar( ) .background(barColor) .then( - if (isSelected && !isFuture) { + if (isSelected && !isFuture && isScheduled) { Modifier.border( width = 2.dp, color = color, @@ -137,8 +143,9 @@ fun NumericWeekBar( style = MaterialTheme.typography.labelSmall, fontWeight = if (isToday) FontWeight.ExtraBold else FontWeight.Normal, color = when { - isSelected && !isFuture -> color - isFuture -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + isSelected && !isFuture && isScheduled -> color + isFuture || !isScheduled -> + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) else -> MaterialTheme.colorScheme.onSurfaceVariant }, fontSize = 11.sp @@ -147,7 +154,11 @@ fun NumericWeekBar( text = date.dayOfMonth.toString(), style = MaterialTheme.typography.bodySmall, fontWeight = if (isToday) FontWeight.ExtraBold else FontWeight.Medium, - color = if (isToday) color else MaterialTheme.colorScheme.onSurface + color = when { + !isScheduled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + isToday -> color + else -> MaterialTheme.colorScheme.onSurface + } ) } } diff --git a/feature/habits/src/main/res/values-en/strings.xml b/feature/habits/src/main/res/values-en/strings.xml index dd149512..bd0a1c15 100644 --- a/feature/habits/src/main/res/values-en/strings.xml +++ b/feature/habits/src/main/res/values-en/strings.xml @@ -9,6 +9,7 @@ Color Scheduled days Select at least one day + Not scheduled today Create Cancel Habits diff --git a/feature/habits/src/main/res/values-es/strings.xml b/feature/habits/src/main/res/values-es/strings.xml index 9d2ca2a8..cd22fd5e 100644 --- a/feature/habits/src/main/res/values-es/strings.xml +++ b/feature/habits/src/main/res/values-es/strings.xml @@ -9,6 +9,7 @@ Color Días programados Selecciona al menos un día + No programado para hoy Crear Cancelar Hábitos diff --git a/feature/habits/src/main/res/values/strings.xml b/feature/habits/src/main/res/values/strings.xml index 111ab839..f0a8f01e 100644 --- a/feature/habits/src/main/res/values/strings.xml +++ b/feature/habits/src/main/res/values/strings.xml @@ -8,6 +8,7 @@ Color Scheduled days Select at least one day + Not scheduled today Create Cancel Habits diff --git a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt index 6b8a3454..6f8bbb88 100644 --- a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt +++ b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt @@ -59,13 +59,18 @@ class HabitViewModelTest { } // ── Helpers ─────────────────────────────────────────────────────────────── - private fun habit(id: Int = 1, name: String = "Test habit") = Habit( + private fun habit( + id: Int = 1, + name: String = "Test habit", + scheduledDays: Set = DayOfWeek.entries.toSet() + ) = Habit( id = id, name = name, iconName = "icon", colorArgb = 0xFF123456.toInt(), habitType = HabitType.BOOLEAN, - createdAt = LocalDate.now().minusDays(7) + createdAt = LocalDate.now().minusDays(7), + scheduledDays = scheduledDays ) private fun habitWithStatus(habit: Habit = habit(), completedToday: Boolean = false) = @@ -170,6 +175,62 @@ class HabitViewModelTest { } } + @Test + fun uiState_progressCountsOnlyHabitsScheduledToday() = runTest { + // GIVEN + val today = LocalDate.now() + val anotherDay = today.dayOfWeek.plus(1) + coEvery { getHabitsWithStatus() } returns flowOf( + listOf( + habitWithStatus( + habit(id = 1, scheduledDays = setOf(today.dayOfWeek)), + completedToday = true + ), + habitWithStatus( + habit(id = 2, scheduledDays = setOf(anotherDay)), + completedToday = true + ) + ) + ) + coEvery { getAllBooleanEntries() } returns flowOf(emptyList()) + + // WHEN + viewModel = buildViewModel() + + // THEN + viewModel.uiState.test { + val successState = awaitItem().let { + if (it is HabitUiState.Loading) awaitItem() else it + } as HabitUiState.Success + + assertEquals(1, successState.totalHabits) + assertEquals(1, successState.completedToday) + } + } + + @Test + fun uiState_withNoHabitsScheduledToday_hasEmptyProgress() = runTest { + // GIVEN + val anotherDay = LocalDate.now().dayOfWeek.plus(1) + coEvery { getHabitsWithStatus() } returns flowOf( + listOf(habitWithStatus(habit(scheduledDays = setOf(anotherDay)))) + ) + coEvery { getAllBooleanEntries() } returns flowOf(emptyList()) + + // WHEN + viewModel = buildViewModel() + + // THEN + viewModel.uiState.test { + val successState = awaitItem().let { + if (it is HabitUiState.Loading) awaitItem() else it + } as HabitUiState.Success + + assertEquals(0, successState.totalHabits) + assertEquals(0, successState.completedToday) + } + } + @Test fun uiState_entriesGroupedByHabitId_inWeekEntriesByHabit() = runTest { // GIVEN - Two entries for habit 1, one for habit 2 From d169a613356c6d93f6dd425bcfb863735ec16d5e Mon Sep 17 00:00:00 2001 From: Markel15 Date: Sat, 22 Aug 2026 19:30:15 +0200 Subject: [PATCH 04/14] feat: phase 4 with streak calculation errors - Daily habits carry over the previous behavior. - Unscheduled days neither break nor increment the streak. - Skipping a scheduled occurrence breaks the streak. - A pending current day does not break the streak yet. - Entries logged on unscheduled days are ignored. - Entries made before the habit creation date do not count. - Boolean and numeric habits use the exact same logic. - For numeric habits: - With a goal, the value must reach the goal. - Without a goal, the value must be greater than zero. --- .../core/domain/HabitStreakCalculator.kt | 103 +++++++++ .../habits/GetHabitsWithStatusUseCase.kt | 57 ++--- .../domain/GetHabitsWithStatusUseCaseTest.kt | 96 ++++++++ .../core/domain/HabitStreakCalculatorTest.kt | 211 ++++++++++++++++++ .../habits/details/HabitDetailViewModel.kt | 108 +++------ .../habits/HabitDetailViewModelTest.kt | 54 ++++- 6 files changed, 512 insertions(+), 117 deletions(-) create mode 100644 core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/GetHabitsWithStatusUseCaseTest.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt new file mode 100644 index 00000000..1b4764be --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt @@ -0,0 +1,103 @@ +package com.markel.flowstate.core.domain + +import java.time.DayOfWeek +import java.time.LocalDate + +/** + * Calculates streaks in scheduled habit occurrences rather than consecutive + * calendar days. Days on which the habit is not scheduled are ignored. + */ +object HabitStreakCalculator { + + fun current( + completedDates: Collection, + scheduledDays: Set, + today: LocalDate = LocalDate.now(), + startedOn: LocalDate? = null + ): Int { + if (completedDates.isEmpty() || scheduledDays.isEmpty()) return 0 + + val validDates = completedDates.asSequence() + .filter { it.dayOfWeek in scheduledDays } + .filter { !it.isAfter(today) } + .filter { startedOn == null || !it.isBefore(startedOn) } + .toSet() + + if (validDates.isEmpty()) return 0 + + var expected = if (today.dayOfWeek in scheduledDays && today in validDates) { + today + } else { + previousScheduledDate(today, scheduledDays) + } + + var streak = 0 + while ((startedOn == null || !expected.isBefore(startedOn)) && expected in validDates) { + streak++ + expected = previousScheduledDate(expected, scheduledDays) + } + return streak + } + + fun best( + completedDates: Collection, + scheduledDays: Set, + startedOn: LocalDate? = null + ): Int { + if (completedDates.isEmpty() || scheduledDays.isEmpty()) return 0 + + val validDates = completedDates.asSequence() + .filter { it.dayOfWeek in scheduledDays } + .filter { startedOn == null || !it.isBefore(startedOn) } + .distinct() + .sorted() + .toList() + + if (validDates.isEmpty()) return 0 + + var best = 1 + var current = 1 + for (index in 1 until validDates.size) { + val previous = validDates[index - 1] + val date = validDates[index] + if (date == nextScheduledDate(previous, scheduledDays)) { + current++ + best = maxOf(best, current) + } else { + current = 1 + } + } + return best + } + + fun qualifyingNumericDates( + entries: Collection, + targetValue: Float? + ): Set = entries.asSequence() + .filter { entry -> + if (targetValue != null) entry.value >= targetValue else entry.value > 0f + } + .mapTo(mutableSetOf()) { it.date } + + private fun previousScheduledDate( + date: LocalDate, + scheduledDays: Set + ): LocalDate { + var candidate = date.minusDays(1) + while (candidate.dayOfWeek !in scheduledDays) { + candidate = candidate.minusDays(1) + } + return candidate + } + + private fun nextScheduledDate( + date: LocalDate, + scheduledDays: Set + ): LocalDate { + var candidate = date.plusDays(1) + while (candidate.dayOfWeek !in scheduledDays) { + candidate = candidate.plusDays(1) + } + return candidate + } +} diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt index 1052bffd..1a4ad00f 100644 --- a/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt @@ -4,6 +4,7 @@ import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.HabitEntryFlat import com.markel.flowstate.core.domain.HabitNumericEntry import com.markel.flowstate.core.domain.HabitRepository +import com.markel.flowstate.core.domain.HabitStreakCalculator import com.markel.flowstate.core.domain.HabitType import com.markel.flowstate.core.domain.HabitWithStatus import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -11,7 +12,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import java.time.DayOfWeek import java.time.LocalDate import javax.inject.Inject @@ -78,7 +78,13 @@ class GetHabitsWithStatusUseCase @Inject constructor( HabitType.BOOLEAN -> { val entries = boolEntriesByHabit[habit.id] ?: emptyList() val isCompletedToday = entries.any { it.epochDay == today } - val streak = calculateStreak(entries.map { it.epochDay }, date) + val completedDates = entries.map { LocalDate.ofEpochDay(it.epochDay) } + val streak = HabitStreakCalculator.current( + completedDates = completedDates, + scheduledDays = habit.scheduledDays, + today = date, + startedOn = habit.createdAt + ) HabitWithStatus( habit = habit, isCompletedToday = isCompletedToday, @@ -97,7 +103,16 @@ class GetHabitsWithStatusUseCase @Inject constructor( else -> todayValue > 0f } - val streak = calculateNumericStreak(entries, habit.targetValue, date) + val completedDates = HabitStreakCalculator.qualifyingNumericDates( + entries = entries, + targetValue = habit.targetValue + ) + val streak = HabitStreakCalculator.current( + completedDates = completedDates, + scheduledDays = habit.scheduledDays, + today = date, + startedOn = habit.createdAt + ) HabitWithStatus( habit = habit, @@ -110,40 +125,4 @@ class GetHabitsWithStatusUseCase @Inject constructor( } } - private fun calculateStreak(epochDays: List, from: LocalDate): Int { - if (epochDays.isEmpty()) return 0 - val sorted = epochDays.toSortedSet(reverseOrder()) - var streak = 0 - var expected = from.toEpochDay() - - // If today is not completed, we start counting from yesterday - if (expected !in sorted) expected-- - - while (expected in sorted) { - streak++ - expected-- - } - return streak - } - - private fun calculateNumericStreak( - entries: List, - targetValue: Float?, - from: LocalDate - ): Int { - if (entries.isEmpty()) return 0 - val validDays = entries - .filter { targetValue == null || it.value >= targetValue } - .map { it.date.toEpochDay() } - .toSortedSet(reverseOrder()) - - var streak = 0 - var expected = from.toEpochDay() - if (expected !in validDays) expected-- - while (expected in validDays) { - streak++ - expected-- - } - return streak - } } \ No newline at end of file diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/GetHabitsWithStatusUseCaseTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/GetHabitsWithStatusUseCaseTest.kt new file mode 100644 index 00000000..34f7d0e7 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/GetHabitsWithStatusUseCaseTest.kt @@ -0,0 +1,96 @@ +package com.markel.flowstate.core.domain + +import com.markel.flowstate.core.domain.usecase.habits.GetHabitsWithStatusUseCase +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +class GetHabitsWithStatusUseCaseTest { + + private val repository: HabitRepository = mockk() + private val useCase = GetHabitsWithStatusUseCase(repository) + private val monday = LocalDate.of(2026, 8, 17) + private val friday = monday.plusDays(4) + private val schedule = setOf( + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.FRIDAY + ) + + @Test + fun booleanHabit_usesScheduledOccurrencesForCurrentStreak() = runTest { + val habit = habit(type = HabitType.BOOLEAN) + every { repository.getHabits() } returns flowOf(listOf(habit)) + every { repository.getAllEntries() } returns flowOf( + listOf( + HabitEntryFlat(habit.id, monday.toEpochDay()), + HabitEntryFlat(habit.id, monday.plusDays(2).toEpochDay()), + HabitEntryFlat(habit.id, friday.toEpochDay()) + ) + ) + + val result = useCase(friday).first().single() + + assertTrue(result.isCompletedToday) + assertEquals(3, result.streak) + } + + @Test + fun numericHabit_usesSameScheduledStreakLogic() = runTest { + val habit = habit(type = HabitType.NUMERIC, targetValue = 10f) + every { repository.getHabits() } returns flowOf(listOf(habit)) + every { repository.getAllEntries() } returns flowOf(emptyList()) + every { repository.getNumericEntries(habit.id) } returns flowOf( + listOf( + numericEntry(monday, 12f), + numericEntry(monday.plusDays(2), 10f), + numericEntry(friday, 15f) + ) + ) + + val result = useCase(friday).first().single() + + assertTrue(result.isCompletedToday) + assertEquals(3, result.streak) + } + + @Test + fun numericHabit_valueBelowTargetBreaksScheduledStreak() = runTest { + val habit = habit(type = HabitType.NUMERIC, targetValue = 10f) + every { repository.getHabits() } returns flowOf(listOf(habit)) + every { repository.getAllEntries() } returns flowOf(emptyList()) + every { repository.getNumericEntries(habit.id) } returns flowOf( + listOf( + numericEntry(monday, 12f), + numericEntry(monday.plusDays(2), 5f), + numericEntry(friday, 15f) + ) + ) + + val result = useCase(friday).first().single() + + assertEquals(1, result.streak) + } + + private fun habit(type: HabitType, targetValue: Float? = null) = Habit( + id = 1, + name = "Exercise", + habitType = type, + targetValue = targetValue, + createdAt = monday.minusWeeks(1), + scheduledDays = schedule + ) + + private fun numericEntry(date: LocalDate, value: Float) = HabitNumericEntry( + habitId = 1, + date = date, + value = value + ) +} diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt new file mode 100644 index 00000000..0698b941 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt @@ -0,0 +1,211 @@ +package com.markel.flowstate.core.domain + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +class HabitStreakCalculatorTest { + + private val monday = LocalDate.of(2026, 8, 17) + private val everyDay = DayOfWeek.entries.toSet() + private val mondayWednesdayFriday = setOf( + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.FRIDAY + ) + + @Test + fun current_dailyHabit_preservesConsecutiveDayBehaviour() { + val completed = setOf( + monday, + monday.minusDays(1), + monday.minusDays(2), + monday.minusDays(4) + ) + + assertEquals( + 3, + HabitStreakCalculator.current(completed, everyDay, today = monday) + ) + } + + @Test + fun current_pendingScheduledToday_doesNotBreakPreviousStreak() { + val completed = setOf( + monday.minusWeeks(1), + monday.minusWeeks(2) + ) + + assertEquals( + 2, + HabitStreakCalculator.current( + completedDates = completed, + scheduledDays = setOf(DayOfWeek.MONDAY), + today = monday + ) + ) + } + + @Test + fun current_ignoresDaysBetweenScheduledOccurrences() { + val friday = monday.plusDays(4) + val completed = setOf( + monday, + monday.plusDays(2), + friday + ) + + assertEquals( + 3, + HabitStreakCalculator.current( + completedDates = completed, + scheduledDays = mondayWednesdayFriday, + today = friday + ) + ) + } + + @Test + fun current_missedScheduledOccurrence_breaksStreak() { + val friday = monday.plusDays(4) + val completed = setOf(monday, friday) + + assertEquals( + 1, + HabitStreakCalculator.current( + completedDates = completed, + scheduledDays = mondayWednesdayFriday, + today = friday + ) + ) + } + + @Test + fun current_afterMissedScheduledOccurrence_isZero() { + val thursday = monday.plusDays(3) + + assertEquals( + 0, + HabitStreakCalculator.current( + completedDates = setOf(monday), + scheduledDays = mondayWednesdayFriday, + today = thursday + ) + ) + } + + @Test + fun current_ignoresCompletionOnUnscheduledDay() { + val wednesday = monday.plusDays(2) + val tuesday = monday.plusDays(1) + + assertEquals( + 2, + HabitStreakCalculator.current( + completedDates = setOf(monday, tuesday, wednesday), + scheduledDays = setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY), + today = wednesday + ) + ) + } + + @Test + fun current_handlesSundayToMondayTransition() { + val sunday = monday.minusDays(1) + + assertEquals( + 2, + HabitStreakCalculator.current( + completedDates = setOf(sunday, monday), + scheduledDays = setOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY), + today = monday + ) + ) + } + + @Test + fun current_doesNotCountEntriesBeforeHabitCreation() { + assertEquals( + 1, + HabitStreakCalculator.current( + completedDates = setOf(monday.minusDays(1), monday), + scheduledDays = everyDay, + today = monday, + startedOn = monday + ) + ) + } + + @Test + fun best_countsLongestRunOfScheduledOccurrences() { + val completed = setOf( + monday, + monday.plusDays(2), + monday.plusDays(4), + monday.plusWeeks(1), + monday.plusWeeks(1).plusDays(4) + ) + + assertEquals( + 4, + HabitStreakCalculator.best(completed, mondayWednesdayFriday) + ) + } + + @Test + fun best_dailyHabit_preservesConsecutiveDayBehaviour() { + val completed = setOf( + monday, + monday.plusDays(1), + monday.plusDays(3), + monday.plusDays(4), + monday.plusDays(5) + ) + + assertEquals(3, HabitStreakCalculator.best(completed, everyDay)) + } + + @Test + fun emptySchedule_hasNoStreak() { + assertEquals( + 0, + HabitStreakCalculator.current(setOf(monday), emptySet(), today = monday) + ) + assertEquals(0, HabitStreakCalculator.best(setOf(monday), emptySet())) + } + + @Test + fun qualifyingNumericDates_requiresTargetWhenPresent() { + val entries = listOf( + numericEntry(monday, 10f), + numericEntry(monday.plusDays(1), 9f), + numericEntry(monday.plusDays(2), 12f) + ) + + assertEquals( + setOf(monday, monday.plusDays(2)), + HabitStreakCalculator.qualifyingNumericDates(entries, targetValue = 10f) + ) + } + + @Test + fun qualifyingNumericDates_withoutTarget_requiresPositiveValue() { + val entries = listOf( + numericEntry(monday, 1f), + numericEntry(monday.plusDays(1), 0f), + numericEntry(monday.plusDays(2), -1f) + ) + + assertEquals( + setOf(monday), + HabitStreakCalculator.qualifyingNumericDates(entries, targetValue = null) + ) + } + + private fun numericEntry(date: LocalDate, value: Float) = HabitNumericEntry( + habitId = 1, + date = date, + value = value + ) +} diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt index 3caa3747..2f09bf8a 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt @@ -6,6 +6,7 @@ import com.markel.flowstate.core.data.UserPreferencesRepository import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.HabitNumericEntry import com.markel.flowstate.core.domain.HabitRepository +import com.markel.flowstate.core.domain.HabitStreakCalculator import com.markel.flowstate.core.domain.HabitType import com.markel.flowstate.core.domain.usecase.habits.GetHabitByIdUseCase import com.markel.flowstate.core.domain.usecase.habits.GetNumericEntriesUseCase @@ -59,41 +60,61 @@ class HabitDetailViewModel @AssistedInject constructor( _uiState.update { it.copy(habit = habit) } if (habit.habitType == HabitType.BOOLEAN) { - loadBooleanHabitData() + loadBooleanHabitData(habit) } else { - loadNumericHabitData() + loadNumericHabitData(habit) } } } - private suspend fun loadBooleanHabitData() { + private suspend fun loadBooleanHabitData(habit: Habit) { habitRepository.getEntriesForHabit(habitId).collect { entries -> val epochDays = entries.map { it.toEpochDay() }.toSet() _uiState.update { state -> state.copy( allEntries = epochDays, - currentStreak = calculateCurrentStreak(epochDays), - bestStreak = calculateBestStreak(epochDays), + currentStreak = HabitStreakCalculator.current( + completedDates = entries, + scheduledDays = habit.scheduledDays, + startedOn = habit.createdAt + ), + bestStreak = HabitStreakCalculator.best( + completedDates = entries, + scheduledDays = habit.scheduledDays, + startedOn = habit.createdAt + ), weeklyCompletions = calculateWeeklyCompletions(epochDays), - dayOfWeekCompletions = calculateDayOfWeekCompletions(entries, state.habit?.createdAt) + dayOfWeekCompletions = calculateDayOfWeekCompletions(entries, habit.createdAt) ) } } } - private suspend fun loadNumericHabitData() { + private suspend fun loadNumericHabitData(habit: Habit) { getNumericDetails(habitId).collect { entries -> val entriesMap = entries.associate { it.date to it.value } + val completedDates = HabitStreakCalculator.qualifyingNumericDates( + entries = entries, + targetValue = habit.targetValue + ) _uiState.update { state -> state.copy( numericEntries = entriesMap, dailyValues = calculateDailyValues(entries), - monthlyProgress = calculateMonthlyProgress(entries, state.habit), + monthlyProgress = calculateMonthlyProgress(entries, habit), heatmapData = calculateHeatmapData(entries), - dayOfWeekAverages = calculateDayOfWeekAverages(entries, state.habit?.createdAt), - currentStreak = calculateNumericStreak(entries, state.habit?.targetValue), - bestStreak = calculateNumericBestStreak(entries, state.habit?.targetValue) + dayOfWeekAverages = calculateDayOfWeekAverages(entries, habit.createdAt), + currentStreak = HabitStreakCalculator.current( + completedDates = completedDates, + scheduledDays = habit.scheduledDays, + startedOn = habit.createdAt + ), + bestStreak = HabitStreakCalculator.best( + completedDates = completedDates, + scheduledDays = habit.scheduledDays, + startedOn = habit.createdAt + ) ) } } @@ -179,28 +200,6 @@ class HabitDetailViewModel @AssistedInject constructor( // ── Calculations for Boolean Habits ───────────────────────────────────────────────────── - private fun calculateCurrentStreak(epochDays: Set): Int { - if (epochDays.isEmpty()) return 0 - var streak = 0 - var expected = LocalDate.now().toEpochDay() - if (expected !in epochDays) expected-- - while (expected in epochDays) { streak++; expected-- } - return streak - } - - private fun calculateBestStreak(epochDays: Set): Int { - if (epochDays.isEmpty()) return 0 - val sorted = epochDays.sorted() - var best = 1; var current = 1 - for (i in 1 until sorted.size) { - if (sorted[i] == sorted[i - 1] + 1) { - current++ - if (current > best) best = current - } else current = 1 - } - return best - } - private fun calculateWeeklyCompletions(epochDays: Set): List> { val today = LocalDate.now() val weeks = 16 @@ -362,47 +361,4 @@ class HabitDetailViewModel @AssistedInject constructor( .associate { it.date to it.value } } - private fun calculateNumericStreak( - entries: List, - targetValue: Float? - ): Int { - if (entries.isEmpty()) return 0 - val validDays = entries - .filter { targetValue == null || it.value >= targetValue } - .map { it.date.toEpochDay() } - .toSortedSet(reverseOrder()) - - var streak = 0 - var expected = LocalDate.now().toEpochDay() - if (expected !in validDays) expected-- - while (expected in validDays) { - streak++ - expected-- - } - return streak - } - - private fun calculateNumericBestStreak( - entries: List, - targetValue: Float? - ): Int { - if (entries.isEmpty()) return 0 - val validDays = entries - .filter { targetValue == null || it.value >= targetValue } - .map { it.date.toEpochDay() } - .sorted() - - if (validDays.isEmpty()) return 0 - - var best = 1 - var current = 1 - for (i in 1 until validDays.size) { - if (validDays[i] == validDays[i - 1] + 1) { - current++ - if (current > best) best = current - } else current = 1 - } - return best - } - } \ No newline at end of file diff --git a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt index f2375feb..d365a43d 100644 --- a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt +++ b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt @@ -51,7 +51,8 @@ class HabitDetailViewModelTest { private fun habit( id: Int = 1, type: HabitType = HabitType.BOOLEAN, - targetValue: Float? = null + targetValue: Float? = null, + scheduledDays: Set = DayOfWeek.entries.toSet() ) = Habit( id = id, name = "Test habit", @@ -59,7 +60,8 @@ class HabitDetailViewModelTest { colorArgb = 0xFF123456.toInt(), habitType = type, targetValue = targetValue, - createdAt = LocalDate.now().minusDays(30) + createdAt = LocalDate.now().minusDays(30), + scheduledDays = scheduledDays ) private fun numericEntry(date: LocalDate, value: Float) = HabitNumericEntry( @@ -177,6 +179,29 @@ class HabitDetailViewModelTest { } } + @Test + fun scheduledBooleanHabit_countsOnlyScheduledOccurrencesInStreaks() = runTest { + // GIVEN + val today = LocalDate.now() + val scheduledDates = listOf(today.minusDays(4), today.minusDays(2), today) + val scheduledDays = scheduledDates.mapTo(mutableSetOf()) { it.dayOfWeek } + coEvery { getHabitById(1) } returns habit( + type = HabitType.BOOLEAN, + scheduledDays = scheduledDays + ) + coEvery { habitRepository.getEntriesForHabit(1) } returns flowOf(scheduledDates) + + // WHEN + viewModel = buildViewModel() + + // THEN + viewModel.uiState.test { + val state = awaitItem() + assertEquals(3, state.currentStreak) + assertEquals(3, state.bestStreak) + } + } + @Test fun calculateDayOfWeekCompletions_groupsCorrectlyAsRates() = runTest { // GIVEN - 2 Mondays and 1 Friday, habit created 30 days ago @@ -229,6 +254,31 @@ class HabitDetailViewModelTest { } } + @Test + fun scheduledNumericHabit_usesSameStreakRulesAsBooleanHabit() = runTest { + // GIVEN + val today = LocalDate.now() + val scheduledDates = listOf(today.minusDays(4), today.minusDays(2), today) + val scheduledDays = scheduledDates.mapTo(mutableSetOf()) { it.dayOfWeek } + val entries = scheduledDates.map { numericEntry(it, 10f) } + coEvery { getHabitById(1) } returns habit( + type = HabitType.NUMERIC, + targetValue = 10f, + scheduledDays = scheduledDays + ) + coEvery { getNumericDetails(1) } returns flowOf(entries) + + // WHEN + viewModel = buildViewModel() + + // THEN + viewModel.uiState.test { + val state = awaitItem() + assertEquals(3, state.currentStreak) + assertEquals(3, state.bestStreak) + } + } + @Test fun calculateNumericBestStreak_ignoresDaysBelowTarget() = runTest { // GIVEN - Target is 10f From 42a3bd97f2868b7ad4124cd631a817ec3b243b29 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Sat, 22 Aug 2026 22:05:53 +0200 Subject: [PATCH 05/14] fix: count backfilled entries in habit streaks (phase 4) After the phase 4 patch, a freshly created daily habit with three consecutive completed days showed, a current streak of 1 instead of 3. Root cause ---------- The bug was not in the streak iteration itself but in the startedOn parameter introduced together with HabitStreakCalculator in phase 4. The calculator dropped every completion dated before habit.createdAt: .filter { startedOn == null || !it.isBefore(startedOn) } The natural way to exercise the feature is to create the habit today and then backfill the previous days from the week calendar, so every entry except today's was filtered out and the streak collapsed to 1. The code before the patch counted those entries, so this is a regression introduced by the patch itself. Fix --- Remove startedOn from HabitStreakCalculator.current() and .best() and from all six call sites (GetHabitsWithStatusUseCase x2, HabitDetailViewModel x4). Every completion on a scheduled day now counts towards the streak. All other phase 4 behavior is preserved: - streaks count scheduled occurrences, not calendar days - a pending scheduled today keeps the streak alive (grace occurrence) - unscheduled-day completions are ignored - numeric habits keep the targetValue threshold (value >= target, or value > 0 without target) Tests ----- - Replace current_doesNotCountEntriesBeforeHabitCreation with current_countsBackfilledEntriesCompletedBeforeCreation and add the matching best() case for the new semantics. - Verified with the module's real code executed in a standalone harness (fake HabitRepository + flows): all HabitStreakCalculatorTest and GetHabitsWithStatusUseCaseTest cases pass, the reported scenario (daily habit created today + 3 backfilled consecutive days) returns a streak of 3, and the old-habit regression check stays at 3. --- .../core/domain/HabitStreakCalculator.kt | 15 ++++++++------- .../habits/GetHabitsWithStatusUseCase.kt | 6 ++---- .../core/domain/HabitStreakCalculatorTest.kt | 18 ++++++++++++++---- .../habits/details/HabitDetailViewModel.kt | 12 ++++-------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt index 1b4764be..d3f7aab2 100644 --- a/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt @@ -6,25 +6,28 @@ import java.time.LocalDate /** * Calculates streaks in scheduled habit occurrences rather than consecutive * calendar days. Days on which the habit is not scheduled are ignored. + * + * Entries completed on dates before the habit's creation date (for example + * backfilled from the week calendar) still count towards the streak. */ object HabitStreakCalculator { fun current( completedDates: Collection, scheduledDays: Set, - today: LocalDate = LocalDate.now(), - startedOn: LocalDate? = null + today: LocalDate = LocalDate.now() ): Int { if (completedDates.isEmpty() || scheduledDays.isEmpty()) return 0 val validDates = completedDates.asSequence() .filter { it.dayOfWeek in scheduledDays } .filter { !it.isAfter(today) } - .filter { startedOn == null || !it.isBefore(startedOn) } .toSet() if (validDates.isEmpty()) return 0 + // If today is scheduled but still pending, the streak survives one + // grace occurrence: counting starts from the previous scheduled date. var expected = if (today.dayOfWeek in scheduledDays && today in validDates) { today } else { @@ -32,7 +35,7 @@ object HabitStreakCalculator { } var streak = 0 - while ((startedOn == null || !expected.isBefore(startedOn)) && expected in validDates) { + while (expected in validDates) { streak++ expected = previousScheduledDate(expected, scheduledDays) } @@ -41,14 +44,12 @@ object HabitStreakCalculator { fun best( completedDates: Collection, - scheduledDays: Set, - startedOn: LocalDate? = null + scheduledDays: Set ): Int { if (completedDates.isEmpty() || scheduledDays.isEmpty()) return 0 val validDates = completedDates.asSequence() .filter { it.dayOfWeek in scheduledDays } - .filter { startedOn == null || !it.isBefore(startedOn) } .distinct() .sorted() .toList() diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt index 1a4ad00f..afd46487 100644 --- a/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/habits/GetHabitsWithStatusUseCase.kt @@ -82,8 +82,7 @@ class GetHabitsWithStatusUseCase @Inject constructor( val streak = HabitStreakCalculator.current( completedDates = completedDates, scheduledDays = habit.scheduledDays, - today = date, - startedOn = habit.createdAt + today = date ) HabitWithStatus( habit = habit, @@ -110,8 +109,7 @@ class GetHabitsWithStatusUseCase @Inject constructor( val streak = HabitStreakCalculator.current( completedDates = completedDates, scheduledDays = habit.scheduledDays, - today = date, - startedOn = habit.createdAt + today = date ) HabitWithStatus( diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt index 0698b941..a4c00ac7 100644 --- a/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt @@ -125,14 +125,24 @@ class HabitStreakCalculatorTest { } @Test - fun current_doesNotCountEntriesBeforeHabitCreation() { + fun current_countsBackfilledEntriesCompletedBeforeCreation() { assertEquals( - 1, + 2, HabitStreakCalculator.current( completedDates = setOf(monday.minusDays(1), monday), scheduledDays = everyDay, - today = monday, - startedOn = monday + today = monday + ) + ) + } + + @Test + fun best_countsBackfilledEntriesCompletedBeforeCreation() { + assertEquals( + 3, + HabitStreakCalculator.best( + completedDates = setOf(monday.minusDays(2), monday.minusDays(1), monday), + scheduledDays = everyDay ) ) } diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt index 2f09bf8a..94e70215 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt @@ -75,13 +75,11 @@ class HabitDetailViewModel @AssistedInject constructor( allEntries = epochDays, currentStreak = HabitStreakCalculator.current( completedDates = entries, - scheduledDays = habit.scheduledDays, - startedOn = habit.createdAt + scheduledDays = habit.scheduledDays ), bestStreak = HabitStreakCalculator.best( completedDates = entries, - scheduledDays = habit.scheduledDays, - startedOn = habit.createdAt + scheduledDays = habit.scheduledDays ), weeklyCompletions = calculateWeeklyCompletions(epochDays), dayOfWeekCompletions = calculateDayOfWeekCompletions(entries, habit.createdAt) @@ -107,13 +105,11 @@ class HabitDetailViewModel @AssistedInject constructor( dayOfWeekAverages = calculateDayOfWeekAverages(entries, habit.createdAt), currentStreak = HabitStreakCalculator.current( completedDates = completedDates, - scheduledDays = habit.scheduledDays, - startedOn = habit.createdAt + scheduledDays = habit.scheduledDays ), bestStreak = HabitStreakCalculator.best( completedDates = completedDates, - scheduledDays = habit.scheduledDays, - startedOn = habit.createdAt + scheduledDays = habit.scheduledDays ) ) } From eda6c5a6f66bb2a7e65054ac31af4a9be5e3df84 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Sun, 23 Aug 2026 20:58:23 +0200 Subject: [PATCH 06/14] feat: finish phase 4 Now the Detail state and consequently the stats use the scheduled days to calculate all the graphics. New component to centralize some of these calculations was created. --- .../core/domain/HabitStatsCalculator.kt | 60 ++++++++ .../core/domain/HabitStatsCalculatorTest.kt | 140 ++++++++++++++++++ .../details/HabitDetailUiStateHelpers.kt | 21 ++- .../habits/details/HabitDetailViewModel.kt | 9 +- .../habits/HabitDetailViewModelTest.kt | 35 +++++ 5 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStatsCalculator.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStatsCalculatorTest.kt diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStatsCalculator.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStatsCalculator.kt new file mode 100644 index 00000000..bfeaca9e --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStatsCalculator.kt @@ -0,0 +1,60 @@ +package com.markel.flowstate.core.domain + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * Scheduled-days-aware statistics for habits. Complements + * [HabitStreakCalculator] with counters/denominators so that habits + * scheduled only some weekdays are measured against their own + * opportunities instead of against every calendar day. + */ +object HabitStatsCalculator { + + /** + * Number of days in [start]..[end] (both inclusive) whose weekday is in + * [scheduledDays]. Returns 0 for an empty schedule or inverted range. + * O(7) — no day-by-day iteration. + */ + fun countScheduledDaysBetween( + start: LocalDate, + end: LocalDate, + scheduledDays: Set + ): Int { + if (start.isAfter(end) || scheduledDays.isEmpty()) return 0 + val totalDays = ChronoUnit.DAYS.between(start, end) + 1 + val startDow = start.dayOfWeek.value + return scheduledDays.sumOf { dow -> + val diff = (dow.value - startDow + 7) % 7L + val occurrences = if (diff < totalDays) 1L + (totalDays - 1L - diff) / 7L else 0L + occurrences.toInt() + } + } + + /** + * Completion percentage (floor, 0..100) of the scheduled occurrences in + * [start]..[end] (both inclusive) that were completed. Completions on + * unscheduled weekdays are ignored, matching the streak semantics. + * + * Returns null when the range has no scheduled occurrences, so callers + * can decide what to display instead of dividing by zero. + */ + fun consistencyPercent( + completedDates: Collection, + scheduledDays: Set, + start: LocalDate, + end: LocalDate + ): Int? { + val opportunities = countScheduledDaysBetween(start, end, scheduledDays) + if (opportunities == 0) return null + val done = completedDates + .asSequence() + .distinct() + .count { date -> + date.dayOfWeek in scheduledDays && + !date.isBefore(start) && !date.isAfter(end) + } + return done * 100 / opportunities + } +} diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStatsCalculatorTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStatsCalculatorTest.kt new file mode 100644 index 00000000..89dfc94a --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStatsCalculatorTest.kt @@ -0,0 +1,140 @@ +package com.markel.flowstate.core.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +class HabitStatsCalculatorTest { + + private val monday = LocalDate.of(2026, 8, 17) + private val everyDay = DayOfWeek.entries.toSet() + private val mondayWednesdayFriday = setOf( + DayOfWeek.MONDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.FRIDAY + ) + + @Test + fun countScheduledDaysBetween_fullWeek_countsEveryScheduledWeekdayOnce() { + val sunday = monday.plusDays(6) + assertEquals( + 7, + HabitStatsCalculator.countScheduledDaysBetween(monday, sunday, everyDay) + ) + assertEquals( + 3, + HabitStatsCalculator.countScheduledDaysBetween(monday, sunday, mondayWednesdayFriday) + ) + } + + @Test + fun countScheduledDaysBetween_twoWeeks_mwf_countsSix() { + val end = monday.plusDays(13) + assertEquals( + 6, + HabitStatsCalculator.countScheduledDaysBetween(monday, end, mondayWednesdayFriday) + ) + } + + @Test + fun countScheduledDaysBetween_singleDay_countsOnlyIfScheduled() { + assertEquals( + 1, + HabitStatsCalculator.countScheduledDaysBetween(monday, monday, mondayWednesdayFriday) + ) + assertEquals( + 0, + HabitStatsCalculator.countScheduledDaysBetween( + monday.plusDays(1), monday.plusDays(1), mondayWednesdayFriday + ) + ) + } + + @Test + fun countScheduledDaysBetween_partialWeek_countsElapsedOccurrencesOnly() { + // Mon 17 .. Sat 22 of the same week -> M, W, F = 3 + assertEquals( + 3, + HabitStatsCalculator.countScheduledDaysBetween( + monday, monday.plusDays(5), mondayWednesdayFriday + ) + ) + } + + @Test + fun countScheduledDaysBetween_invertedRangeOrEmptySchedule_isZero() { + assertEquals( + 0, + HabitStatsCalculator.countScheduledDaysBetween(monday, monday.minusDays(1), everyDay) + ) + assertEquals( + 0, + HabitStatsCalculator.countScheduledDaysBetween(monday, monday.plusDays(6), emptySet()) + ) + } + + @Test + fun consistencyPercent_scheduledHabit_measuresAgainstScheduledOccurrences() { + // Two full weeks (M/W/F -> 6 opportunities), 5 done on scheduled + // days and 1 extra completion on an unscheduled day that is ignored + val end = monday.plusDays(13) + val completed = setOf( + monday, monday.plusDays(2), monday.plusDays(4), + monday.plusDays(7), monday.plusDays(9), + monday.plusDays(1) // Tuesday: not scheduled, must not count + ) + + assertEquals( + 83, // floor(5 * 100 / 6) + HabitStatsCalculator.consistencyPercent(completed, mondayWednesdayFriday, monday, end) + ) + } + + @Test + fun consistencyPercent_dailyHabit_everyScheduledDayDone_is100() { + val end = monday.plusDays(6) + val completed = (0..6).map { monday.plusDays(it.toLong()) } + + assertEquals( + 100, + HabitStatsCalculator.consistencyPercent(completed, everyDay, monday, end) + ) + } + + @Test + fun consistencyPercent_partialDailyCompletion_floorsLikeBefore() { + val end = monday.plusDays(6) + val completed = setOf(monday, monday.plusDays(1), monday.plusDays(2)) + + assertEquals( + 42, // floor(3 * 100 / 7), mimics the old calendar-day math + HabitStatsCalculator.consistencyPercent(completed, everyDay, monday, end) + ) + } + + @Test + fun consistencyPercent_noScheduledOccurrencesInRange_isNull() { + // Sunday-only habit measured over Mon..Sat + assertNull( + HabitStatsCalculator.consistencyPercent( + completedDates = setOf(monday), + scheduledDays = setOf(DayOfWeek.SUNDAY), + start = monday, + end = monday.plusDays(5) + ) + ) + } + + @Test + fun consistencyPercent_duplicatedCompletions_doNotInflateTheResult() { + val end = monday.plusDays(6) + val completed = listOf(monday, monday, monday.plusDays(1)) + + assertEquals( + 28, // 2 distinct completed days out of 7 + HabitStatsCalculator.consistencyPercent(completed, everyDay, monday, end) + ) + } +} diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailUiStateHelpers.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailUiStateHelpers.kt index fdc41296..1f5b76d6 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailUiStateHelpers.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailUiStateHelpers.kt @@ -4,25 +4,30 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import java.time.LocalDate import java.time.format.TextStyle +import com.markel.flowstate.core.domain.HabitStatsCalculator import com.markel.flowstate.feature.habits.R import java.time.Month -import java.time.temporal.ChronoUnit import java.util.Locale +/** + * Consistency over the visible range, measured against the habit's + * scheduled days. + */ fun HabitDetailUiState.completionPct(): String { - if (allEntries.isEmpty()) return "0%" + val habit = habit ?: return "0%" val now = LocalDate.now() val start = when (viewMode) { CalendarViewMode.ONE_MONTH -> now.withDayOfMonth(1) CalendarViewMode.THREE_MONTHS -> now.withDayOfMonth(1).minusMonths(2) CalendarViewMode.ONE_YEAR -> now.withDayOfYear(1) } - val days = ChronoUnit.DAYS.between(start, now.plusDays(1)).toInt() - val done = allEntries.count { epochDay -> - val d = LocalDate.ofEpochDay(epochDay) - !d.isBefore(start) && !d.isAfter(now) - } - return "${(done * 100 / days)}%" + val pct = HabitStatsCalculator.consistencyPercent( + completedDates = allEntries.map { LocalDate.ofEpochDay(it) }, + scheduledDays = habit.scheduledDays, + start = start, + end = now + ) ?: return "0%" + return "$pct%" } @Composable diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt index 94e70215..6e84fec6 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/details/HabitDetailViewModel.kt @@ -6,6 +6,7 @@ import com.markel.flowstate.core.data.UserPreferencesRepository import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.HabitNumericEntry import com.markel.flowstate.core.domain.HabitRepository +import com.markel.flowstate.core.domain.HabitStatsCalculator import com.markel.flowstate.core.domain.HabitStreakCalculator import com.markel.flowstate.core.domain.HabitType import com.markel.flowstate.core.domain.usecase.habits.GetHabitByIdUseCase @@ -285,7 +286,13 @@ class HabitDetailViewModel @AssistedInject constructor( val daysCompleted = dailyMaxValues.count { (_, maxValue) -> maxValue >= (habit.targetValue ?: 0f) } - val totalDays = yearMonth.lengthOfMonth() + // In scheduled habits the month goal/denominator uses only the + // scheduled days of the month, not every calendar day + val totalDays = HabitStatsCalculator.countScheduledDaysBetween( + start = yearMonth.atDay(1), + end = yearMonth.atEndOfMonth(), + scheduledDays = habit.scheduledDays + ) val dailyAverage = if (daysWithData > 0) currentValue / daysWithData else 0f val monthName = now.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) diff --git a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt index d365a43d..2e6c503d 100644 --- a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt +++ b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt @@ -337,6 +337,41 @@ class HabitDetailViewModelTest { } } + @Test + fun calculateMonthlyProgress_countsOnlyScheduledDaysOfTheMonth() = runTest { + // GIVEN - Mon/Wed/Fri habit with a 5f per-session target + val today = LocalDate.now() + val currentMonth = YearMonth.now() + val scheduledDays = setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY) + val scheduledDatesInMonth = (1..currentMonth.lengthOfMonth()) + .map { currentMonth.atDay(it) } + .filter { it.dayOfWeek in scheduledDays } + // Entries on the elapsed scheduled days of the month (robust on month boundaries) + val elapsedScheduled = scheduledDatesInMonth.filter { !it.isAfter(today) } + val entries = elapsedScheduled.take(2).mapIndexed { index, date -> + numericEntry(date, if (index == 0) 10f else 2f) + } + coEvery { getHabitById(1) } returns habit( + type = HabitType.NUMERIC, + targetValue = 5f, + scheduledDays = scheduledDays + ) + coEvery { getNumericDetails(1) } returns flowOf(entries) + + // WHEN + viewModel = buildViewModel() + + // THEN + viewModel.uiState.test { + val progress = awaitItem().monthlyProgress + assertNotNull(progress) + assertEquals(scheduledDatesInMonth.size, progress!!.totalDays) + assertEquals(5f * scheduledDatesInMonth.size, progress.targetValue!!) + assertEquals(entries.sumOf { it.value.toDouble() }.toFloat(), progress.currentValue) + assertEquals(entries.count { it.value >= 5f }, progress.daysCompleted) + } + } + @Test fun calculateDayOfWeekAverages_averagesOverAllOpportunities() = runTest { // GIVEN - Two Mondays with values 10 and 20, habit created 30 days ago From cf39f433a59ede3ad63f9c9e8171abaff20c4301 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Mon, 24 Aug 2026 02:59:44 +0200 Subject: [PATCH 07/14] feat: add differentiation for the widget when the habit is not scheduled that day. The widget changes the color to be surfaceVariant and if you click on it, it will show a message informing the next scheduled day for that habit. --- .../com/markel/flowstate/core/domain/Habit.kt | 11 +++ .../core/domain/HabitNextScheduledDateTest.kt | 68 +++++++++++++++++++ .../flowstate/core/widgets/HabitWidget.kt | 23 +++++-- .../core/widgets/ToggleHabitAction.kt | 37 +++++++++- .../src/main/res/values-en/strings.xml | 2 + .../src/main/res/values-es/strings.xml | 2 + core/widgets/src/main/res/values/strings.xml | 2 + 7 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/HabitNextScheduledDateTest.kt diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt index 9e92c49f..cebf8cfe 100644 --- a/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/Habit.kt @@ -21,6 +21,17 @@ data class Habit( fun Habit.isScheduledFor(date: LocalDate): Boolean = date.dayOfWeek in scheduledDays +/** + * The first scheduled date strictly after [after], at most one week + * ahead. Searches at most 7 days, so it always terminates, and returns + * null for a defensive edge case: a habit with an empty schedule. + */ +fun Habit.nextScheduledDate(after: LocalDate): LocalDate? = + (1L..7L) + .asSequence() + .map { after.plusDays(it) } + .firstOrNull { isScheduledFor(it) } + data class HabitWithStatus( val habit: Habit, val isCompletedToday: Boolean, diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitNextScheduledDateTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitNextScheduledDateTest.kt new file mode 100644 index 00000000..8db50e8a --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitNextScheduledDateTest.kt @@ -0,0 +1,68 @@ +package com.markel.flowstate.core.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.DayOfWeek +import java.time.LocalDate + +class HabitNextScheduledDateTest { + + private val monday = LocalDate.of(2026, 8, 17) + + private fun habit(scheduledDays: Set) = Habit( + name = "Test habit", + scheduledDays = scheduledDays + ) + + @Test + fun nextScheduledDate_monWedFri_afterMonday_isWednesday() { + assertEquals( + monday.plusDays(2), + habit(setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)) + .nextScheduledDate(monday) + ) + } + + @Test + fun nextScheduledDate_monWedFri_afterFriday_wrapsToNextMonday() { + val friday = monday.plusDays(4) + assertEquals( + monday.plusWeeks(1), + habit(setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)) + .nextScheduledDate(friday) + ) + } + + @Test + fun nextScheduledDate_dailyHabit_isTheFollowingDay() { + assertEquals( + monday.plusDays(1), + habit(DayOfWeek.entries.toSet()).nextScheduledDate(monday) + ) + } + + @Test + fun nextScheduledDate_singleWeekdaySchedule_isSevenDaysLater() { + assertEquals( + monday.plusWeeks(1), + habit(setOf(DayOfWeek.MONDAY)).nextScheduledDate(monday) + ) + } + + @Test + fun nextScheduledDate_emptySchedule_isNullAndTerminates() { + assertNull(habit(emptySet()).nextScheduledDate(monday)) + } + + @Test + fun nextScheduledDate_referenceDayScheduled_stillReturnsTheNextOccurrence() { + // "after" itself is scheduled, but the contract is strictly later + val wednesdayHabit = habit(setOf(DayOfWeek.WEDNESDAY)) + val fromWednesday = monday.plusDays(2) + assertEquals( + fromWednesday.plusWeeks(1), + wednesdayHabit.nextScheduledDate(fromWednesday) + ) + } +} diff --git a/core/widgets/src/main/java/com/markel/flowstate/core/widgets/HabitWidget.kt b/core/widgets/src/main/java/com/markel/flowstate/core/widgets/HabitWidget.kt index 465a8405..48fb727e 100644 --- a/core/widgets/src/main/java/com/markel/flowstate/core/widgets/HabitWidget.kt +++ b/core/widgets/src/main/java/com/markel/flowstate/core/widgets/HabitWidget.kt @@ -39,6 +39,7 @@ import java.time.LocalDate import com.markel.flowstate.core.designsystem.icon.HabitIconMapper import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.HabitRepository +import com.markel.flowstate.core.domain.isScheduledFor import kotlinx.coroutines.flow.first // Key preferences for the widget @@ -109,12 +110,14 @@ class HabitWidget : GlanceAppWidget() { val today = LocalDate.now() val isCompleted = entries.contains(today) + val isScheduledToday = currentHabit.isScheduledFor(today) // Render the widget subscribed to the flow data WidgetPill( habitName = currentHabit.name, iconName = currentHabit.iconName, isCompleted = isCompleted, + isScheduledToday = isScheduledToday, dayNumber = today.dayOfMonth ) } @@ -141,17 +144,27 @@ class HabitWidget : GlanceAppWidget() { habitName: String, iconName: String, isCompleted: Boolean, + isScheduledToday: Boolean, dayNumber: Int ) { - val backgColor = GlanceTheme.colors.primaryContainer - val backgIconColor = GlanceTheme.colors.onPrimaryContainer + // On rest days (habit not scheduled today) the pill switches to the + // muted surfaceVariant color role, so it reads as "nothing to do + // today" at a glance in both light and dark dynamic themes + val backgColor = + if (isScheduledToday) GlanceTheme.colors.primaryContainer + else GlanceTheme.colors.surfaceVariant + val backgIconColor = + if (isScheduledToday) GlanceTheme.colors.onPrimaryContainer + else GlanceTheme.colors.onSurfaceVariant val badgeColor = if (isCompleted) GlanceTheme.colors.primary else GlanceTheme.colors.primaryContainer - val badgeTextColor = - if (isCompleted) GlanceTheme.colors.onPrimary - else GlanceTheme.colors.onPrimaryContainer + val badgeTextColor = when { + isCompleted -> GlanceTheme.colors.onPrimary + !isScheduledToday -> GlanceTheme.colors.onSurfaceVariant + else -> GlanceTheme.colors.onPrimaryContainer + } val size = LocalSize.current val widgetSize = if (size.width < size.height) size.width else size.height diff --git a/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt b/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt index 19cf01a1..c9815247 100644 --- a/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt +++ b/core/widgets/src/main/java/com/markel/flowstate/core/widgets/ToggleHabitAction.kt @@ -2,15 +2,22 @@ package com.markel.flowstate.core.widgets import android.content.Context import android.content.Intent +import android.widget.Toast import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition +import com.markel.flowstate.core.domain.Habit import com.markel.flowstate.core.domain.isScheduledFor +import com.markel.flowstate.core.domain.nextScheduledDate import dagger.hilt.android.EntryPointAccessors import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class ToggleHabitAction : ActionCallback { @@ -33,7 +40,13 @@ class ToggleHabitAction : ActionCallback { val repository = entryPoint.habitRepository() val habit = repository.getHabitById(habitId) ?: return val today = LocalDate.now() - if (!habit.isScheduledFor(today)) return + + // Rest day: tapping cannot toggle anything, so instead of failing + // silently tell the user the habit is off today and when it returns + if (!habit.isScheduledFor(today)) { + showRestDayToast(context, habit, today) + return + } // For the numeric habits (should be impossible, but in any case) open the app if (habit.habitType.name == "NUMERIC") { @@ -51,4 +64,26 @@ class ToggleHabitAction : ActionCallback { // Force redraw of the widget HabitWidget().update(context, glanceId) } + + private suspend fun showRestDayToast(context: Context, habit: Habit, today: LocalDate) { + val message = when (val next = habit.nextScheduledDate(today)) { + null -> + // Defensive fallback for an empty schedule (impossible by + // domain invariants today, but never hang on a toast) + context.getString(R.string.widget_rest_day_toast_no_next, habit.name) + + else -> { + val nextDayName = next.dayOfWeek + .getDisplayName(TextStyle.FULL, Locale.getDefault()) + .replaceFirstChar { it.uppercase(Locale.getDefault()) } + context.getString(R.string.widget_rest_day_toast, habit.name, nextDayName) + } + } + + // Toasts need a thread with a Looper: Glance runs action callbacks + // off the main thread, and a failing Toast is swallowed silently + withContext(Dispatchers.Main) { + Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).show() + } + } } diff --git a/core/widgets/src/main/res/values-en/strings.xml b/core/widgets/src/main/res/values-en/strings.xml index 1d6deb92..827fad3a 100644 --- a/core/widgets/src/main/res/values-en/strings.xml +++ b/core/widgets/src/main/res/values-en/strings.xml @@ -5,4 +5,6 @@ You don\'t have basic habits yet Cancel Not configured widget + "%1$s" is off today. Next: %2$s + "%1$s" is off today. \ No newline at end of file diff --git a/core/widgets/src/main/res/values-es/strings.xml b/core/widgets/src/main/res/values-es/strings.xml index 3fce79da..822851f7 100644 --- a/core/widgets/src/main/res/values-es/strings.xml +++ b/core/widgets/src/main/res/values-es/strings.xml @@ -5,4 +5,6 @@ No tienes hábitos booleanos aún Cancelar Widget no configurado + «%1$s» no toca hoy. Próximo: %2$s + «%1$s» no toca hoy. \ No newline at end of file diff --git a/core/widgets/src/main/res/values/strings.xml b/core/widgets/src/main/res/values/strings.xml index 773f890d..3a2e40cb 100644 --- a/core/widgets/src/main/res/values/strings.xml +++ b/core/widgets/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ You don\'t have basic habits yet Cancel Not configured widget + "%1$s" is off today. Next: %2$s + "%1$s" is off today. From 24cdd47b1fac061e7982be36d14fa882be428ac0 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Mon, 24 Aug 2026 20:34:37 +0200 Subject: [PATCH 08/14] feat: improve visually the scheduled days selector Make the selector more coherent with the material-3-like style of the app. --- .../habits/components/AddHabitSheet.kt | 132 +++++++++--------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt index 84fccd0f..f224ae4a 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton @@ -38,6 +39,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.toShape import androidx.compose.runtime.Composable @@ -249,73 +252,6 @@ fun AddHabitSheet( } } - // ── Schedule ───────────────────────────────────────────────── - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringResource(R.string.habit_schedule_title), - style = MaterialTheme.typography.labelLarge - ) - Text( - text = stringResource(R.string.habit_schedule_at_least_one), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - val locale = LocalLocale.current.platformLocale - DayOfWeek.entries.forEach { day -> - val isSelected = day in scheduledDays - val canToggle = !isSelected || scheduledDays.size > 1 - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1f) - .height(42.dp) - .clip(CircleShape) - .background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHighest - ) - .border( - width = 1.dp, - color = if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant, - shape = CircleShape - ) - .semantics { - contentDescription = day.getDisplayName(TextStyle.FULL, locale) - } - .toggleable( - value = isSelected, - enabled = canToggle, - role = Role.Checkbox - ) { checked -> - scheduledDays = if (checked) { - scheduledDays + day - } else { - scheduledDays - day - } - } - ) { - Text( - text = day - .getDisplayName(TextStyle.NARROW, locale) - .uppercase(locale), - style = MaterialTheme.typography.labelLarge, - color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - } - } - } - // ── Icon picker ────────────────────────────────────────────── Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(stringResource(R.string.icon), style = MaterialTheme.typography.labelLarge) @@ -356,6 +292,68 @@ fun AddHabitSheet( } } + // ── Scheduled days ── + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(R.string.habit_schedule_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + ButtonGroupDefaults.ConnectedSpaceBetween + ) + ) { + val locale = LocalLocale.current.platformLocale + DayOfWeek.entries.forEachIndexed { index, day -> + val isSelected = day in scheduledDays + val canToggle = !isSelected || scheduledDays.size > 1 + + ToggleButton( + checked = isSelected, + onCheckedChange = { checked -> + scheduledDays = + if (checked) scheduledDays + day + else scheduledDays - day + }, + enabled = canToggle, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + DayOfWeek.entries.lastIndex -> + ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }.let { base -> + base.copy(checkedShape = base.shape) + }, + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.primaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.primaryContainer, + disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .weight(1f) + .semantics { + contentDescription = day.getDisplayName(TextStyle.FULL, locale) + } + ) { + Text( + text = day + .getDisplayName(TextStyle.NARROW, locale) + .uppercase(locale), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + Text( + text = stringResource(R.string.habit_schedule_at_least_one), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + // ── Actions (M3 Expressive press morph) ─ Row( horizontalArrangement = Arrangement.spacedBy(8.dp), From 9e7be0a0e1d11e2e5beae2a3222f8f8c12e288a2 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Mon, 24 Aug 2026 22:13:42 +0200 Subject: [PATCH 09/14] feat: improve the numeric habit creation/edition sheet Show in the same row some fields to make it feel less overwhelming. Before this the sheet was extremely high because all the fields were stacked up. --- .../habits/components/AddHabitSheet.kt | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt index f224ae4a..4be68b96 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/AddHabitSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -191,49 +192,61 @@ fun AddHabitSheet( modifier = Modifier.fillMaxWidth() ) - // ── Numeric-only fields ────────────────────────────────────── + // ── Numeric-only fields ───────────────────────────────────── if (habitType == HabitType.NUMERIC) { - Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { - OutlinedTextField( - value = unit, - onValueChange = { unit = it }, - label = { Text(stringResource(R.string.habit_unit_label)) }, - placeholder = { - Text( - text = " h, km, kg...", - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - ) - }, - singleLine = true, - shape = MaterialTheme.shapes.large, - modifier = Modifier.fillMaxWidth(), - supportingText = { Text("") } // keep padding even between fields - ) - OutlinedTextField( - value = targetValueText, - onValueChange = { targetValueText = it }, - label = { - Text("${stringResource(R.string.habit_target_label)} (${stringResource(R.string.habit_target_optional)})") - }, - placeholder = { - Text( - text = "2", - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - shape = MaterialTheme.shapes.large, - modifier = Modifier.fillMaxWidth(), - isError = isTargetInvalid, - supportingText = { - when { - isTargetInvalid -> Text(text = stringResource(R.string.habit_target_error)) - unit.isNotBlank() && targetValueText.isNotBlank() -> - Text(stringResource(R.string.habit_target_preview, targetValueText, unit)) + Column { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = unit, + onValueChange = { unit = it }, + label = { Text(stringResource(R.string.habit_unit_label)) }, + placeholder = { + Text( + text = "km, h, kg...", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + }, + singleLine = true, + shape = MaterialTheme.shapes.large, + modifier = Modifier.weight(1f), + supportingText = { + if (unit.isNotBlank() && targetValueText.isNotBlank()) + Text( + stringResource( + R.string.habit_target_preview, + targetValueText, + unit + ) + ) + else Text("") // keep the baselines aligned with the goal field } - } - ) + ) + OutlinedTextField( + value = targetValueText, + onValueChange = { targetValueText = it }, + label = { Text(stringResource(R.string.habit_target_label)) }, + placeholder = { + Text( + text = "2", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = MaterialTheme.shapes.large, + modifier = Modifier.width(112.dp), + isError = isTargetInvalid, + supportingText = { + when { + isTargetInvalid -> + Text(text = stringResource(R.string.habit_target_error)) + targetValueText.isBlank() -> + Text(text = stringResource(R.string.habit_target_optional)) + else -> Text("") // preview shown under the unit field + } + } + ) + } OutlinedTextField( value = stepText, onValueChange = { stepText = it }, From a81d34919b2810492b2eab7203aab5a76bc97299 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Tue, 25 Aug 2026 01:39:30 +0200 Subject: [PATCH 10/14] docs: update readme Update it and organize it better visually. --- README.md | 159 ++++++++++++++++++++++++++---------------------------- 1 file changed, 77 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index ecd82ed8..8622bcce 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,70 @@ # FlowState -**A modern, intuitive task management app built with Jetpack Compose and Clean Architecture** +**Tasks, habits and notes — all in one place. Free, open-source, 100% offline.** -[![Kotlin](https://img.shields.io/badge/Kotlin-2.2.10-blue.svg)](https://kotlinlang.org) -[![Compose](https://img.shields.io/badge/Compose-1.10.1-blue)](fhttps://developer.android.com/jetpack/compose) -[![Room](https://img.shields.io/badge/Room-2.8.4-green)](https://developer.android.com/training/data-storage/room) -[![Hilt](https://img.shields.io/badge/Hilt-2.57.2-red)](https://dagger.dev/hilt/) +No accounts, no ads, no tracking, no servers. Your data never leaves your phone. + +[![Release](https://img.shields.io/github/v/release/Markel15/FlowState)](https://github.com/Markel15/FlowState/releases/latest) +[![License](https://img.shields.io/github/license/Markel15/FlowState)](LICENSE) +[![Android](https://img.shields.io/badge/Android-12%2B_(API_31)-3DDC84?logo=android&logoColor=white)](https://www.android.com) + +## Download +**Get the latest APK — no store, no sign-up:** + +[![Download FlowState APK](https://img.shields.io/badge/⬇_Download-FlowState_APK-2ea44f?style=for-the-badge&logo=android&logoColor=white)](https://github.com/Markel15/FlowState/releases/latest/download/app-release.apk) + +1. Download the APK (Android 12 or newer). +2. Open it and tap **Install** — Android will ask you to allow installs from your browser once. +3. That's it. Updates install on top of the previous version and the data remains. + +> 💡 **Tip:** use [Obtainium](https://obtainium.imranr.dev/) to get automatic updates straight from these GitHub releases. +> Older versions and changelogs live in [Releases](https://github.com/Markel15/FlowState/releases). ## Screenshots

- + Task listMiscCalendarHabitsHabit detailsHabit details

## Features -### Implemented -- **Task Management**: Create, edit and delete tasks -- **Priority System**: Set task priorities (High, Medium, Low, None) -- **Sub-tasks**: Break down tasks into manageable sub-tasks -- **Swipe Actions**: Swipe to delete tasks with smooth animations -- **Drag & Drop**: Reorder tasks via intuitive drag handles -- **Auto-save**: Changes are saved automatically when editing a task -- **Material 3**: Modern design -- **Task Scheduling**: With due dates and reminders -- **Calendar** view -- **Habit Tracking**: Build daily routines and track streaks -- **Widgets** for basic habits - -### In Development -- **Widgets**: More widgets -- **Mood Tracking**: Log daily emotions and view trends -- **Improve UX**: with the subtasks -- Any new **suggestion** +**Tasks** +- Create, edit and delete tasks with **sub-tasks**, priorities and due dates +- Swipe actions and drag & drop reordering +- Auto-save while editing and reminders + +**Habits** +- Boolean and **numeric** habits (kilometers, glasses of water, minutes…) +- Streaks and progress statistics +- Home-screen **widgets** + +**Notes, checklists & ideas** — quick capture built in + +**Calendar view** of your schedule + +**Make it yours** +- Material 3 Expressive design with **Material You** dynamic colors +- Pure black/white theme options and system font support +- Reorderable category tabs — organize the app your way +- English & Spanish + +**Privacy by design** +- 100% offline: no accounts, no analytics, no tracking +- Local database only, with **backup & restore** + +### Planned / in progress +- More widgets +- Mood tracking +- Your suggestions → [open an issue](https://github.com/Markel15/flowstate/issues) ## Motivation -I wanted to build a fully-featured Android app completely **free**, **ad-free**, and **privacy-focused** — no servers, no tracking, no data collection, works 100% offline. -This project was mainly a way for me to learn modern **Kotlin** (coming from a Java-only background) while creating something truly **customizable** that I (and others) can easily modify, extend and adapt to different needs. +I wanted to build a fully-featured Android app that is completely **free**, **ad-free** and **privacy-focused** — no servers, no tracking, no data collection, working 100% offline. +This project was also a way for me to learn modern **Kotlin** (coming from a Java-only background) while creating something truly **customizable** that others can easily modify, extend and adapt. -## Getting Started +
+Build from source ### Prerequisites - Android Studio @@ -52,59 +76,14 @@ This project was mainly a way for me to learn modern **Kotlin** (coming from a J ```bash git clone https://github.com/Markel15/flowstate.git cd flowstate - -2. **Open in Android Studio** - - -Open Android Studio - - -Select "Open an Existing Project" - - -Navigate to the cloned directory - -3. **Build and Run** - - -Connect an Android device or start an emulator (API 31+) - - -Click the Run button + ``` +2. **Open in Android Studio** → *Open an Existing Project* → select the cloned directory. +3. **Build and Run** on a device or emulator running API 31+. -## How to Contribute +
-We welcome contributions! Here's how you can help: - -### Reporting Bugs -1. Check the [Issues](https://github.com/Markel15/flowstate/issues) to avoid duplicates -2. Create a new issue with: - - Clear description - - Steps to reproduce - - Expected vs actual behavior - - Screenshots/videos if possible - - Device/OS information - -### Suggesting Features -1. Check existing feature requests -2. Create an issue with: - - Use case description - - Mockups/wireframes if applicable - - Priority justification - -### Submitting Code -1. **Fork the repository** -2. **Create a feature branch** - ```bash - git checkout -b feature/amazing-feature -3. **Make your changes** - - - Write tests when possible - - Update documentation when possible -4. **Commit your changes** - ```bash - git commit -m 'Add some amazing feature' -5. Push to the branch - ```bash - git push origin feature/amazing-feature -6. Open a Pull Request - -## Architecture +
+Architecture & tech stack FlowState follows **Clean Architecture** with a clear separation of concerns: @@ -117,7 +96,7 @@ UI Layer (Presentation) Domain Layer (Business Logic) ├── Use Cases (Interactors) ├── Repository Interfaces -└── Domain Models (Task, SubTask, Priority) +└── Domain Models (Task, SubTask, Habit) Data Layer (Infrastructure) ├── Repository Implementations @@ -125,9 +104,6 @@ Data Layer (Infrastructure) └── Data Models (Entities) ``` - -## Tech Stack - | Component | Technology | |----------------|---------------------------| | **UI Framework** | Jetpack Compose | @@ -137,3 +113,22 @@ Data Layer (Infrastructure) | **Async** | Kotlin Coroutines | | **Navigation** | Navigation 3 | | **Animations** | Compose Animation APIs | + +[![Kotlin](https://img.shields.io/badge/Kotlin-2.2.10-blue.svg)](https://kotlinlang.org) +[![Compose](https://img.shields.io/badge/Compose-1.10.1-blue)](https://developer.android.com/jetpack/compose) +[![Room](https://img.shields.io/badge/Room-2.8.4-green)](https://developer.android.com/training/data-storage/room) +[![Hilt](https://img.shields.io/badge/Hilt-2.57.2-red)](https://dagger.dev/hilt/) + +
+ +## How to contribute + +Contributions are welcome! + +- **Found a bug?** Check the [Issues](https://github.com/Markel15/flowstate/issues) to avoid duplicates, then open one with a clear description, steps to reproduce, expected vs actual behavior, and screenshots/device info if possible. +- **Have an idea?** Open an issue describing the use case (mockups welcome). +- **Want to code?** Fork the repo, create a feature branch (`git checkout -b feature/amazing-feature`), make your changes (tests and docs when possible), and open a Pull Request. + +## License + +FlowState is open-source software licensed under the [Apache License 2.0](LICENSE). From bbb4d83011e66ab598ab8b060bd0a2b1032a7a08 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Fri, 28 Aug 2026 04:11:01 +0200 Subject: [PATCH 11/14] feat: achievements v1 Added some achievements to make users happier and more motivated to complete the tasks and habits. --- .../navigation/FlowStateNavDisplay.kt | 8 + .../markel/flowstate/navigation/NavKeys.kt | 1 + .../core/domain/achievements/Achievement.kt | 112 +++++ .../achievements/HabitAchievementMetrics.kt | 149 ++++++ .../domain/achievements/TaskDerivedMetrics.kt | 55 ++ .../GetAchievementMetricsUseCase.kt | 39 ++ .../achievements/AchievementEvaluatorTest.kt | 84 ++++ .../HabitAchievementMetricsTest.kt | 140 ++++++ .../achievements/TaskDerivedMetricsTest.kt | 55 ++ .../feature/settings/AchievementsScreen.kt | 475 ++++++++++++++++++ .../feature/settings/AchievementsViewModel.kt | 36 ++ .../feature/settings/SettingsScreen.kt | 22 + .../src/main/res/drawable/award_star_24px.xml | 10 + .../main/res/drawable/check_circle_24px.xml | 10 + .../src/main/res/drawable/mode_heat_24px.xml | 10 + .../src/main/res/drawable/owl_24px.xml | 10 + .../src/main/res/drawable/star_shine_24px.xml | 10 + .../src/main/res/drawable/view_week_24px.xml | 10 + .../src/main/res/values-en/strings.xml | 24 + .../src/main/res/values-es/strings.xml | 24 + .../settings/src/main/res/values/strings.xml | 24 + 21 files changed, 1308 insertions(+) create mode 100644 core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/Achievement.kt create mode 100644 core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetrics.kt create mode 100644 core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetrics.kt create mode 100644 core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/achievements/GetAchievementMetricsUseCase.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/AchievementEvaluatorTest.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetricsTest.kt create mode 100644 core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetricsTest.kt create mode 100644 feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt create mode 100644 feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt create mode 100644 feature/settings/src/main/res/drawable/award_star_24px.xml create mode 100644 feature/settings/src/main/res/drawable/check_circle_24px.xml create mode 100644 feature/settings/src/main/res/drawable/mode_heat_24px.xml create mode 100644 feature/settings/src/main/res/drawable/owl_24px.xml create mode 100644 feature/settings/src/main/res/drawable/star_shine_24px.xml create mode 100644 feature/settings/src/main/res/drawable/view_week_24px.xml diff --git a/app/src/main/java/com/markel/flowstate/navigation/FlowStateNavDisplay.kt b/app/src/main/java/com/markel/flowstate/navigation/FlowStateNavDisplay.kt index 6d899674..1dae360b 100644 --- a/app/src/main/java/com/markel/flowstate/navigation/FlowStateNavDisplay.kt +++ b/app/src/main/java/com/markel/flowstate/navigation/FlowStateNavDisplay.kt @@ -45,6 +45,7 @@ import com.markel.flowstate.feature.settings.AboutScreen import com.markel.flowstate.feature.settings.AppearanceScreen import com.markel.flowstate.feature.settings.BackupScreen import com.markel.flowstate.feature.settings.BottomNavConfigScreen +import com.markel.flowstate.feature.settings.AchievementsScreen import com.markel.flowstate.feature.settings.CategoriesScreen import com.markel.flowstate.feature.settings.SettingsScreen @@ -170,6 +171,7 @@ fun FlowStateNavDisplay( onNavigateToAppearance = { navigator.navigate(FullScreenKey.Appearance) }, onNavigateToBottomNavConfig = { navigator.navigate(FullScreenKey.BottomNavConfig) }, onNavigateToCategories = { navigator.navigate(FullScreenKey.Categories) }, + onNavigateToAchievements = { navigator.navigate(FullScreenKey.Achievements) }, onNavigateToIntegrations = { navigator.navigate(FullScreenKey.Integrations) }, onNavigateToAbout = { navigator.navigate(FullScreenKey.About) }, notificationsEnabled = notificationsEnabled, @@ -278,6 +280,12 @@ fun FlowStateNavDisplay( ) { CategoriesScreen(onBack = { navigator.goBack() }) } + + entry( + metadata = fullScreenVerticalSlide() + ) { + AchievementsScreen(onBack = { navigator.goBack() }) + } } CompositionLocalProvider(LocalSharedTransitionScope provides sharedTransitionScope) { diff --git a/app/src/main/java/com/markel/flowstate/navigation/NavKeys.kt b/app/src/main/java/com/markel/flowstate/navigation/NavKeys.kt index 230d3897..5460c439 100644 --- a/app/src/main/java/com/markel/flowstate/navigation/NavKeys.kt +++ b/app/src/main/java/com/markel/flowstate/navigation/NavKeys.kt @@ -46,6 +46,7 @@ sealed interface FullScreenKey : NavKey { @Serializable data object BottomNavConfig : FullScreenKey @Serializable data object Integrations : FullScreenKey @Serializable data object Categories : FullScreenKey + @Serializable data object Achievements : FullScreenKey } // ───────────────────────────────────────────────────────────────────────────── diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/Achievement.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/Achievement.kt new file mode 100644 index 00000000..cef1c9aa --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/Achievement.kt @@ -0,0 +1,112 @@ +package com.markel.flowstate.core.domain.achievements + +/** + * Static catalog and pure evaluation logic for the achievements section. + * + * Every achievement is DERIVED from Room data (habit entries, streaks, + * task timestamps): nothing lives in preferences, so progress survives + * device migrations through the regular JSON backup. Achievements tied to + * volatile data (e.g. task timestamps) reflect the current state — if the + * underlying entries are removed, the milestone becomes pending again, + * which matches the meaning of deleting that data. + * + * Definitions live in code (not in the database). UI-specific resources + * (icons, shapes, strings) are mapped from [AchievementId] in the + * presentation layer, keeping this module pure. + */ + +enum class AchievementId { + PERFECT_DAY, + STREAK, + UNSTOPPABLE, + NIGHT_OWL, + FLAWLESS_MONDAY, + SUNDAY_FUN_DAY, + FLAWLESS_WEEK, + COLLECTOR +} + +data class AchievementDefinition( + val id: AchievementId, + val tiers: List +) + +/** Aggregated raw values the catalog is evaluated against. */ +data class AchievementInputs( + val totalHabitCompletions: Int = 0, + val bestStreak: Int = 0, + val perfectDays: Int = 0, + val mondayPerfectDays: Int = 0, + val nightOwlTasks: Int = 0, + val bestSundayTasks: Int = 0, + val perfectWeeks: Int = 0 +) + +data class AchievementProgress( + val definition: AchievementDefinition, + /** Raw current value (can exceed the top tier). */ + val current: Int, + /** How many tiers are unlocked (0..tiers.size). */ + val tierReached: Int, + /** Target of the tier still in progress, or null when maxed out. */ + val nextTier: Int? +) { + val maxed: Boolean get() = nextTier == null +} + +object AchievementCatalog { + + /** Achievement list, in display order. [COLLECTOR] is evaluated last. */ + val definitions: List = listOf( + AchievementDefinition(AchievementId.PERFECT_DAY, listOf(1, 10, 30)), + AchievementDefinition(AchievementId.STREAK, listOf(21, 100, 365)), + AchievementDefinition(AchievementId.UNSTOPPABLE, listOf(100, 1000, 5000)), + AchievementDefinition(AchievementId.NIGHT_OWL, listOf(1)), + AchievementDefinition(AchievementId.FLAWLESS_MONDAY, listOf(1, 4, 10)), + AchievementDefinition(AchievementId.SUNDAY_FUN_DAY, listOf(3, 5, 15)), + AchievementDefinition(AchievementId.FLAWLESS_WEEK, listOf(1, 4, 10)), + AchievementDefinition(AchievementId.COLLECTOR, listOf(4, 8, 12)) + ) +} + +object AchievementEvaluator { + + /** + * Evaluates the catalog. [AchievementId.COLLECTOR] is a meta + * achievement: its current value is the number of tiers unlocked + * across every OTHER achievement. + */ + fun evaluate(inputs: AchievementInputs): List { + val base = AchievementCatalog.definitions + .filterNot { it.id == AchievementId.COLLECTOR } + .map { definition -> toProgress(definition, currentValue(definition.id, inputs)) } + + val unlockedTiers = base.sumOf { it.tierReached } + val collector = AchievementCatalog.definitions + .first { it.id == AchievementId.COLLECTOR } + .let { toProgress(it, unlockedTiers) } + + return base + collector + } + + private fun toProgress(definition: AchievementDefinition, current: Int): AchievementProgress { + val tierReached = definition.tiers.count { current >= it } + return AchievementProgress( + definition = definition, + current = current, + tierReached = tierReached, + nextTier = definition.tiers.getOrNull(tierReached) + ) + } + + private fun currentValue(id: AchievementId, inputs: AchievementInputs): Int = when (id) { + AchievementId.PERFECT_DAY -> inputs.perfectDays + AchievementId.STREAK -> inputs.bestStreak + AchievementId.UNSTOPPABLE -> inputs.totalHabitCompletions + AchievementId.NIGHT_OWL -> inputs.nightOwlTasks + AchievementId.FLAWLESS_MONDAY -> inputs.mondayPerfectDays + AchievementId.SUNDAY_FUN_DAY -> inputs.bestSundayTasks + AchievementId.FLAWLESS_WEEK -> inputs.perfectWeeks + AchievementId.COLLECTOR -> 0 // evaluated in a second pass + } +} diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetrics.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetrics.kt new file mode 100644 index 00000000..1b743086 --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetrics.kt @@ -0,0 +1,149 @@ +package com.markel.flowstate.core.domain.achievements + +import com.markel.flowstate.core.domain.Habit +import com.markel.flowstate.core.domain.HabitEntryFlat +import com.markel.flowstate.core.domain.HabitNumericEntry +import com.markel.flowstate.core.domain.HabitStreakCalculator +import com.markel.flowstate.core.domain.HabitType +import com.markel.flowstate.core.domain.isScheduledFor +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +/** Habit-derived raw values used by the achievement catalog. */ +data class HabitAchievementMetrics( + val totalCompletions: Int, + val bestStreak: Int, + val perfectDays: Int, + val mondayPerfectDays: Int, + val perfectWeeks: Int +) + +/** Completed dates per habit (numeric entries must reach the target). */ +private fun completedDatesByHabit( + habits: List, + booleanEntries: List, + numericEntries: List +): Map> { + val boolDatesByHabit: Map> = booleanEntries + .groupBy({ it.habitId }, { LocalDate.ofEpochDay(it.epochDay) }) + .mapValues { (_, dates) -> dates.toSet() } + + val numericByHabit = numericEntries.groupBy { it.habitId } + + return habits.associate { habit -> + val dates = if (habit.habitType == HabitType.NUMERIC) { + HabitStreakCalculator.qualifyingNumericDates( + numericByHabit[habit.id].orEmpty(), + habit.targetValue + ) + } else { + boolDatesByHabit[habit.id].orEmpty() + } + habit.id to dates + } +} + +/** + * Computes [HabitAchievementMetrics] from the raw habit data. + * + * Pure (no Android, no I/O) so it can be unit-tested. A "perfect day" + * means: every habit that already existed on that date AND was scheduled + * for it is completed. Days with nothing scheduled don't count either way. + */ +fun computeHabitAchievementMetrics( + habits: List, + booleanEntries: List, + numericEntries: List, + today: LocalDate = LocalDate.now() +): HabitAchievementMetrics { + if (habits.isEmpty()) { + return HabitAchievementMetrics(0, 0, 0, 0, 0) + } + + val datesByHabit = completedDatesByHabit(habits, booleanEntries, numericEntries) + + val bestStreak = habits.maxOf { habit -> + HabitStreakCalculator.best( + datesByHabit[habit.id].orEmpty(), + habit.scheduledDays + ) + } + + return HabitAchievementMetrics( + totalCompletions = datesByHabit.values.sumOf { it.size }, + bestStreak = bestStreak, + perfectDays = countPerfectDays(habits, datesByHabit, today), + mondayPerfectDays = countPerfectDays(habits, datesByHabit, today, DayOfWeek.MONDAY), + perfectWeeks = countPerfectWeeks(habits, datesByHabit, today) + ) +} + +/** + * Number of dates in which every habit that already existed and was + * scheduled that day is completed. Optionally restricted to a single + * [onlyDayOfWeek]. The search starts at the oldest habit creation date + * and stops at [today]. + */ +internal fun countPerfectDays( + habits: List, + datesByHabit: Map>, + today: LocalDate, + onlyDayOfWeek: DayOfWeek? = null +): Int { + val start = habits.minOf { it.createdAt } + if (start.isAfter(today)) return 0 + + var perfect = 0 + var date = start + while (!date.isAfter(today)) { + if (onlyDayOfWeek == null || date.dayOfWeek == onlyDayOfWeek) { + val relevant = habits.filter { !it.createdAt.isAfter(date) && it.isScheduledFor(date) } + if (relevant.isNotEmpty() && + relevant.all { date in datesByHabit[it.id].orEmpty() } + ) { + perfect++ + } + } + date = date.plusDays(1) + } + return perfect +} + +/** + * Number of COMPLETE calendar weeks (Monday..Sunday, already finished) + * in which every scheduled habit occurrence is completed. The current + * unfinished week never counts. + */ +internal fun countPerfectWeeks( + habits: List, + datesByHabit: Map>, + today: LocalDate +): Int { + val start = habits.minOf { it.createdAt } + if (start.isAfter(today)) return 0 + + val firstMonday = start.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val lastCompletedWeekStart = + today.with(TemporalAdjusters.previous(DayOfWeek.MONDAY)) + + var perfect = 0 + var weekStart = firstMonday + while (!weekStart.isAfter(lastCompletedWeekStart)) { + var allOk = true + for (offset in 0L..6L) { + val date = weekStart.plusDays(offset) + if (date.isBefore(start) || date.isAfter(today)) continue + val relevant = habits.filter { !it.createdAt.isAfter(date) && it.isScheduledFor(date) } + if (relevant.isNotEmpty() && + !relevant.all { date in datesByHabit[it.id].orEmpty() } + ) { + allOk = false + break + } + } + if (allOk) perfect++ + weekStart = weekStart.plusWeeks(1) + } + return perfect +} diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetrics.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetrics.kt new file mode 100644 index 00000000..c1254b55 --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetrics.kt @@ -0,0 +1,55 @@ +package com.markel.flowstate.core.domain.achievements + +import com.markel.flowstate.core.domain.Task +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** Task-derived raw values used by the achievement catalog. */ +data class TaskDerivedMetrics( + /** Tasks completed between midnight and 5 AM (night owl sessions). */ + val nightOwlTasks: Int, + /** Most tasks completed on a single Sunday. */ + val bestSundayTasks: Int +) + +private const val NIGHT_OWL_END_HOUR = 5 + +/** + * Computes [TaskDerivedMetrics] from the completion timestamps. + * + * Pure apart from the injectable [zone] so unit tests can pin a timezone. + * Timestamps live in `completedAt`, so these values mirror the current + * task list: deleting a completed task rolls its related milestones back. + */ +fun computeTaskDerivedMetrics( + tasks: List, + zone: ZoneId = ZoneId.systemDefault() +): TaskDerivedMetrics { + val completionDates = mutableListOf() + var nightOwl = 0 + + tasks.forEach { task -> + val completedAt = task.completedAt ?: return@forEach + if (!task.isDone) return@forEach + val zoned = Instant.ofEpochMilli(completedAt).atZone(zone) + if (zoned.toLocalTime() < LocalTime.of(NIGHT_OWL_END_HOUR, 0)) { + nightOwl++ + } + completionDates += zoned.toLocalDate() + } + + val bestSunday = completionDates + .filter { it.dayOfWeek == DayOfWeek.SUNDAY } + .groupingBy { it } + .eachCount() + .values + .maxOrNull() ?: 0 + + return TaskDerivedMetrics( + nightOwlTasks = nightOwl, + bestSundayTasks = bestSunday + ) +} diff --git a/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/achievements/GetAchievementMetricsUseCase.kt b/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/achievements/GetAchievementMetricsUseCase.kt new file mode 100644 index 00000000..e06c42de --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/usecase/achievements/GetAchievementMetricsUseCase.kt @@ -0,0 +1,39 @@ +package com.markel.flowstate.core.domain.usecase.achievements + +import com.markel.flowstate.core.domain.HabitRepository +import com.markel.flowstate.core.domain.TaskRepository +import com.markel.flowstate.core.domain.achievements.HabitAchievementMetrics +import com.markel.flowstate.core.domain.achievements.TaskDerivedMetrics +import com.markel.flowstate.core.domain.achievements.computeHabitAchievementMetrics +import com.markel.flowstate.core.domain.achievements.computeTaskDerivedMetrics +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +/** Habit + task derived metrics feeding the achievement catalog. */ +data class AchievementMetrics( + val habit: HabitAchievementMetrics, + val tasks: TaskDerivedMetrics +) + +/** + * Streams the derived achievement metrics from the existing Room data — + * no extra persistence required, so everything travels with the regular + * JSON backup. + */ +class GetAchievementMetricsUseCase @Inject constructor( + private val habitRepository: HabitRepository, + private val taskRepository: TaskRepository +) { + operator fun invoke(): Flow = combine( + habitRepository.getHabits(), + habitRepository.getAllEntries(), + habitRepository.getAllNumericEntries(), + taskRepository.getTasks() + ) { habits, booleanEntries, numericEntries, tasks -> + AchievementMetrics( + habit = computeHabitAchievementMetrics(habits, booleanEntries, numericEntries), + tasks = computeTaskDerivedMetrics(tasks) + ) + } +} diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/AchievementEvaluatorTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/AchievementEvaluatorTest.kt new file mode 100644 index 00000000..95d64ca6 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/AchievementEvaluatorTest.kt @@ -0,0 +1,84 @@ +package com.markel.flowstate.core.domain.achievements + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AchievementEvaluatorTest { + + private fun progress(id: AchievementId, inputs: AchievementInputs): AchievementProgress = + AchievementEvaluator.evaluate(inputs).first { it.definition.id == id } + + @Test + fun `with no activity everything is locked`() { + val results = AchievementEvaluator.evaluate(AchievementInputs()) + assertTrue(results.all { it.tierReached == 0 }) + assertTrue(results.none { it.maxed }) + } + + @Test + fun `streak tiers unlock gradually`() { + val p = progress(AchievementId.STREAK, AchievementInputs(bestStreak = 21)) + assertEquals(1, p.tierReached) + assertEquals(100, p.nextTier) + } + + @Test + fun `streak maxes out at the top tier`() { + val p = progress(AchievementId.STREAK, AchievementInputs(bestStreak = 365)) + assertEquals(3, p.tierReached) + assertTrue(p.maxed) + assertNull(p.nextTier) + assertEquals(365, p.current) // the raw value keeps growing + } + + @Test + fun `night owl is single tier`() { + assertEquals(0, progress(AchievementId.NIGHT_OWL, AchievementInputs()).tierReached) + val p = progress(AchievementId.NIGHT_OWL, AchievementInputs(nightOwlTasks = 1)) + assertEquals(1, p.tierReached) + assertTrue(p.maxed) + } + + @Test + fun `sunday fun day uses the best single sunday`() { + val p = progress(AchievementId.SUNDAY_FUN_DAY, AchievementInputs(bestSundayTasks = 5)) + assertEquals(2, p.tierReached) // 3 and 5 reached + assertEquals(15, p.nextTier) + } + + @Test + fun `collector counts tiers unlocked by other achievements`() { + // 3 tiers from a maxed streak + 1 tier from a perfect day + val inputs = AchievementInputs(bestStreak = 365, perfectDays = 1) + val p = progress(AchievementId.COLLECTOR, inputs) + assertEquals(4, p.current) + assertEquals(1, p.tierReached) + assertEquals(8, p.nextTier) + } + + @Test + fun `collector does not count itself`() { + // Everything maxed: 22 tiers total, collector contributes 3, + // so it must see 19 unlocked tiers (reaching its own top tier). + val inputs = AchievementInputs( + totalHabitCompletions = 5000, + bestStreak = 365, + perfectDays = 30, + mondayPerfectDays = 10, + nightOwlTasks = 1, + bestSundayTasks = 15, + perfectWeeks = 10 + ) + val p = progress(AchievementId.COLLECTOR, inputs) + assertEquals(19, p.current) + assertTrue(p.maxed) + } + + @Test + fun `evaluate returns every catalog entry in order`() { + val results = AchievementEvaluator.evaluate(AchievementInputs()) + assertEquals(AchievementCatalog.definitions.map { it.id }, results.map { it.definition.id }) + } +} diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetricsTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetricsTest.kt new file mode 100644 index 00000000..8996ec73 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/HabitAchievementMetricsTest.kt @@ -0,0 +1,140 @@ +package com.markel.flowstate.core.domain.achievements + +import com.markel.flowstate.core.domain.Habit +import com.markel.flowstate.core.domain.HabitEntryFlat +import com.markel.flowstate.core.domain.HabitNumericEntry +import com.markel.flowstate.core.domain.HabitType +import java.time.DayOfWeek +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class HabitAchievementMetricsTest { + + private val today = LocalDate.of(2026, 8, 24) // a Monday + + private fun boolHabit( + id: Int, + createdAt: LocalDate, + scheduled: Set = DayOfWeek.entries.toSet() + ) = Habit( + id = id, + name = "h$id", + createdAt = createdAt, + habitType = HabitType.BOOLEAN, + scheduledDays = scheduled + ) + + private fun entry(habitId: Int, date: LocalDate) = + HabitEntryFlat(habitId, date.toEpochDay()) + + @Test + fun `no habits means all zeros`() { + val m = computeHabitAchievementMetrics(emptyList(), emptyList(), emptyList(), today) + assertEquals(0, m.totalCompletions) + assertEquals(0, m.bestStreak) + assertEquals(0, m.perfectDays) + assertEquals(0, m.mondayPerfectDays) + assertEquals(0, m.perfectWeeks) + } + + @Test + fun `numeric completions only count when the target is reached`() { + val habit = Habit( + id = 1, + name = "run", + createdAt = today.minusDays(3), + habitType = HabitType.NUMERIC, + targetValue = 5f + ) + val numeric = listOf( + HabitNumericEntry(1, today.minusDays(2), 6f), // qualifies + HabitNumericEntry(1, today.minusDays(1), 2f), // below target + HabitNumericEntry(1, today, 5f) // exactly target + ) + val m = computeHabitAchievementMetrics(listOf(habit), emptyList(), numeric, today) + assertEquals(2, m.totalCompletions) + } + + @Test + fun `best streak is the maximum across habits`() { + val h1 = boolHabit(1, today.minusDays(10)) + val h2 = boolHabit(2, today.minusDays(10)) + val entries = listOf( + entry(1, today.minusDays(2)), + entry(1, today.minusDays(1)), + entry(1, today), + entry(2, today.minusDays(1)), + entry(2, today) + ) + val m = computeHabitAchievementMetrics(listOf(h1, h2), entries, emptyList(), today) + assertEquals(3, m.bestStreak) + assertEquals(5, m.totalCompletions) + } + + @Test + fun `perfect day requires every scheduled habit that already existed`() { + val older = boolHabit(1, today.minusDays(2)) + val newer = boolHabit(2, today) + val entries = listOf( + entry(1, today.minusDays(2)), + entry(1, today.minusDays(1)), + entry(1, today), + entry(2, today) + ) + val m = computeHabitAchievementMetrics(listOf(older, newer), entries, emptyList(), today) + assertEquals(3, m.perfectDays) + } + + @Test + fun `monday perfect days only count mondays`() { + val habit = boolHabit(1, today.minusDays(3)) // created Friday + val entries = listOf( + entry(1, today.minusDays(3)), // Friday + entry(1, today.minusDays(2)), // Saturday + entry(1, today.minusDays(1)), // Sunday + // Monday (today) NOT completed + ) + val m = computeHabitAchievementMetrics(listOf(habit), entries, emptyList(), today) + assertEquals(3, m.perfectDays) + assertEquals(0, m.mondayPerfectDays) + } + + @Test + fun `days with nothing scheduled do not count as perfect`() { + val weekendOnly = boolHabit( + 1, + today.minusDays(7), + scheduled = setOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY) + ) + val m = computeHabitAchievementMetrics(listOf(weekendOnly), emptyList(), emptyList(), today) + assertEquals(0, m.perfectDays) + } + + @Test + fun `perfect week requires the whole completed week`() { + val habit = boolHabit(1, today.minusWeeks(2)) // created 2 weeks ago (Monday) + // Last week (Mon Aug 17 - Sun Aug 23 2026) fully completed except Sunday + val goodDates = (0L..5L).map { today.minusWeeks(1).plusDays(it) } + val previousWeek = (0L..6L).map { today.minusWeeks(2).plusDays(it) } + val entries = (goodDates + previousWeek).map { entry(1, it) } + // today is Monday of the current (unfinished) week, not completed + + val m = computeHabitAchievementMetrics(listOf(habit), entries, emptyList(), today) + // Previous week counts; last week misses Sunday; current week is unfinished + assertEquals(1, m.perfectWeeks) + } + + @Test + fun `current unfinished week never counts`() { + val habit = boolHabit(1, today) + // today (Monday) completed, but the week just started + val m = computeHabitAchievementMetrics( + listOf(habit), + listOf(entry(1, today)), + emptyList(), + today + ) + assertEquals(0, m.perfectWeeks) + } +} diff --git a/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetricsTest.kt b/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetricsTest.kt new file mode 100644 index 00000000..f0a8c195 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/achievements/TaskDerivedMetricsTest.kt @@ -0,0 +1,55 @@ +package com.markel.flowstate.core.domain.achievements + +import com.markel.flowstate.core.domain.Task +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class TaskDerivedMetricsTest { + + private val zone = ZoneId.of("Europe/Madrid") + + private fun doneTask(id: Int, dateTime: LocalDateTime): Task = Task( + id = id, + title = "t$id", + description = "", + isDone = true, + completedAt = dateTime.atZone(zone).toInstant().toEpochMilli() + ) + + @Test + fun `night owl counts tasks between midnight and 5 am`() { + val tasks = listOf( + doneTask(1, LocalDateTime.of(2026, 8, 23, 0, 30)), + doneTask(2, LocalDateTime.of(2026, 8, 23, 4, 59)), + doneTask(3, LocalDateTime.of(2026, 8, 23, 5, 0)), // exactly 5:00 -> no + doneTask(4, LocalDateTime.of(2026, 8, 23, 23, 30)) // evening -> no + ) + assertEquals(2, computeTaskDerivedMetrics(tasks, zone).nightOwlTasks) + } + + @Test + fun `best sunday counts the busiest single sunday`() { + val sundayA = LocalDate.of(2026, 8, 9) // a Sunday + val sundayB = LocalDate.of(2026, 8, 16) // the next Sunday + val tasks = listOf( + doneTask(1, sundayA.atTime(10, 0)), + doneTask(2, sundayA.atTime(12, 0)), + doneTask(3, sundayA.atTime(18, 0)), + doneTask(4, sundayB.atTime(10, 0)), + doneTask(5, sundayB.atTime(20, 0)), + doneTask(6, LocalDate.of(2026, 8, 11).atTime(10, 0)) // Tuesday + ) + assertEquals(3, computeTaskDerivedMetrics(tasks, zone).bestSundayTasks) + } + + @Test + fun `tasks without completion or pending tasks are ignored`() { + val pending = Task(id = 1, title = "p", description = "", isDone = false) + assertEquals(0, computeTaskDerivedMetrics(listOf(pending), zone).nightOwlTasks) + assertEquals(0, computeTaskDerivedMetrics(listOf(pending), zone).bestSundayTasks) + } +} diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt new file mode 100644 index 00000000..635fa0a9 --- /dev/null +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt @@ -0,0 +1,475 @@ +package com.markel.flowstate.feature.settings + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.markel.flowstate.core.designsystem.R as DesignR +import com.markel.flowstate.core.domain.achievements.AchievementId +import com.markel.flowstate.core.domain.achievements.AchievementProgress + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AchievementsScreen( + onBack: () -> Unit, + viewModel: AchievementsViewModel = hiltViewModel() +) { + val achievements by viewModel.achievements.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumFlexibleTopAppBar( + scrollBehavior = scrollBehavior, + title = { + Text( + text = stringResource(R.string.achievements_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + FilledTonalIconButton(onClick = onBack) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.arrow_back_24px), + contentDescription = null + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + scrolledContainerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { innerPadding -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 60.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // ── Hero summary card ───────────────────────────── + if (achievements.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + HeroSummary(achievements) + } + } + + items(achievements, key = { it.definition.id }) { progress -> + AchievementCard(progress) + } + } + } +} + +/** Visual assets per achievement: the icon and its strings. */ +private data class AchievementVisuals(val iconRes: Int, val nameRes: Int, val descRes: Int) + +private fun visualsFor(id: AchievementId): AchievementVisuals = when (id) { + AchievementId.PERFECT_DAY -> AchievementVisuals( + R.drawable.star_shine_24px, + R.string.achievement_perfect_day_name, R.string.achievement_perfect_day_desc + ) + AchievementId.STREAK -> AchievementVisuals( + R.drawable.mode_heat_24px, + R.string.achievement_streak_name, R.string.achievement_streak_desc + ) + AchievementId.UNSTOPPABLE -> AchievementVisuals( + R.drawable.check_circle_24px, + R.string.achievement_unstoppable_name, R.string.achievement_unstoppable_desc + ) + AchievementId.NIGHT_OWL -> AchievementVisuals( + R.drawable.owl_24px, + R.string.achievement_night_owl_name, R.string.achievement_night_owl_desc + ) + AchievementId.FLAWLESS_MONDAY -> AchievementVisuals( + R.drawable.calendar_month_24px, + R.string.achievement_flawless_monday_name, R.string.achievement_flawless_monday_desc + ) + AchievementId.SUNDAY_FUN_DAY -> AchievementVisuals( + R.drawable.task_alt_24px, + R.string.achievement_sunday_fun_day_name, R.string.achievement_sunday_fun_day_desc + ) + AchievementId.FLAWLESS_WEEK -> AchievementVisuals( + R.drawable.view_week_24px, + R.string.achievement_flawless_week_name, R.string.achievement_flawless_week_desc + ) + AchievementId.COLLECTOR -> AchievementVisuals( + R.drawable.award_star_24px, + R.string.achievement_collector_name, R.string.achievement_collector_desc + ) +} + +/** + * Shared visual language: the cookie gains sides as the level grows. + * Locked -> Cookie4 (dimmed), tier1 -> Cookie6, tier2 -> Cookie9, + * tier3 -> Cookie12. Single-tier achievements jump 4 -> 12. + */ +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun shapeForTier(tierReached: Int, totalTiers: Int): Shape = when { + totalTiers == 1 -> if (tierReached >= 1) MaterialShapes.Cookie12Sided.toShape() + else MaterialShapes.Cookie4Sided.toShape() + + else -> when (tierReached) { + 0 -> MaterialShapes.Cookie4Sided.toShape() + 1 -> MaterialShapes.Cookie6Sided.toShape() + 2 -> MaterialShapes.Cookie9Sided.toShape() + else -> MaterialShapes.Cookie12Sided.toShape() + } +} + +/** + * Expressive summary: wavy ring with the unlocked tier count, plus the + * closest achievable next level — information that stays useful no + * matter how often the screen is opened (no motivational filler). + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun HeroSummary(achievements: List) { + val unlocked = achievements.sumOf { it.tierReached } + val total = achievements.sumOf { it.definition.tiers.size } + + // Animate the ring up from 0 when the screen opens / data changes. + var started by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { started = true } + val ringFraction by animateFloatAsState( + targetValue = if (started && total > 0) unlocked.toFloat() / total else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "heroRing" + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + shape = RoundedCornerShape(26.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.achievements_hero_summary, unlocked, total), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Closest next level: the achievement missing the fewest units. + val nextUp = achievements + .filter { it.nextTier != null } + .minByOrNull { (it.nextTier ?: Int.MAX_VALUE) - it.current } + if (nextUp != null) { + val name = stringResource(visualsFor(nextUp.definition.id).nameRes) + Text( + text = stringResource( + R.string.achievements_hero_next, + (nextUp.nextTier ?: 0) - nextUp.current, + name + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 6.dp) + ) + } + } + + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) { + CircularWavyProgressIndicator( + progress = { ringFraction }, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f), + modifier = Modifier.size(84.dp) + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$unlocked", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold + ) + Text( + text = stringResource(R.string.achievements_hero_of, total), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun AchievementCard(progress: AchievementProgress) { + val visuals = visualsFor(progress.definition.id) + val tier = progress.tierReached + val totalTiers = progress.definition.tiers.size + val cookieShape = shapeForTier(tier, totalTiers) + + // The accent mirrors the highest unlocked tier; locked stays dim. + val accent: Color = when (tier) { + 0 -> MaterialTheme.colorScheme.onSurfaceVariant + 1 -> MaterialTheme.colorScheme.primary + 2 -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.tertiary + } + val onAccent: Color = when (tier) { + 0 -> MaterialTheme.colorScheme.surface + 1 -> MaterialTheme.colorScheme.onPrimary + 2 -> MaterialTheme.colorScheme.onSecondary + else -> MaterialTheme.colorScheme.onTertiary + } + val locked = tier == 0 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(72.dp) + .graphicsLayer { + val s = if (locked) 0.9f else 1f + scaleX = s + scaleY = s + alpha = if (locked) 0.55f else 1f + } + .run { + // Soft colored glow on the top tier. + if (tier == totalTiers && !locked) { + shadow( + elevation = 10.dp, + shape = cookieShape, + clip = false, + ambientColor = accent, + spotColor = accent + ) + } else this + } + .background(color = accent, shape = cookieShape) + ) { + if (!locked) { + // Gloss on the top half so the cookie reads as "lit". + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.30f), + 0.45f to Color.Transparent + ), + shape = cookieShape + ) + ) + } + Icon( + imageVector = ImageVector.vectorResource(visuals.iconRes), + contentDescription = stringResource(visuals.nameRes), + tint = onAccent, + modifier = Modifier.size(30.dp) + ) + } + + Text( + text = stringResource(visuals.nameRes), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant + else Color.Unspecified + ) + Text( + text = stringResource(visuals.descRes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (locked) 0.55f else 0.8f), + textAlign = TextAlign.Center, + minLines = 2 + ) + + Text( + text = stringResource(R.string.achievement_level_chip, tier, totalTiers), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.65f) else accent, + modifier = Modifier + .background( + color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.10f) else accent.copy(alpha = 0.14f), + shape = CircleShape + ) + .padding(horizontal = 9.dp, vertical = 2.dp) + ) + + TierDots( + tiers = totalTiers, + reached = tier, + unlockedColor = accent, + modifier = Modifier.height(8.dp) + ) + + // Fixed-height bottom zone: progress or the "maxed" pill, so + // every card in the grid measures exactly the same height. + Column( + modifier = Modifier + .fillMaxWidth() + .height(34.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (progress.maxed) { + Surface( + shape = CircleShape, + color = accent, + contentColor = onAccent, + modifier = Modifier + .fillMaxWidth() + .height(26.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = "★ " + stringResource(R.string.achievement_max_level), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.ExtraBold + ) + } + } + } else { + val target = progress.nextTier ?: return@Column + Text( + text = stringResource( + R.string.achievement_progress_format, + minOf(progress.current, target), + target + ), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 0.2.sp + ) + // Straight bar with a spring — it "grows" to its value + // when the screen opens or the data advances. + var started by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { started = true } + val fraction by animateFloatAsState( + targetValue = if (started) + (progress.current.toFloat() / target).coerceIn(0f, 1f) + else 0f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "cardProgress" + ) + LinearProgressIndicator( + progress = { fraction }, + color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.4f) else accent, + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp) + ) + } + } + } + } +} + +@Composable +private fun TierDots( + tiers: Int, + reached: Int, + unlockedColor: Color, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + repeat(tiers) { index -> + Box( + modifier = Modifier + .size(7.dp) + .background( + color = if (index < reached) unlockedColor + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f), + shape = CircleShape + ) + ) + } + } +} diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt new file mode 100644 index 00000000..03540aee --- /dev/null +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt @@ -0,0 +1,36 @@ +package com.markel.flowstate.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.markel.flowstate.core.domain.achievements.AchievementEvaluator +import com.markel.flowstate.core.domain.achievements.AchievementInputs +import com.markel.flowstate.core.domain.achievements.AchievementProgress +import com.markel.flowstate.core.domain.usecase.achievements.GetAchievementMetricsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class AchievementsViewModel @Inject constructor( + getAchievementMetrics: GetAchievementMetricsUseCase +) : ViewModel() { + + val achievements: StateFlow> = getAchievementMetrics() + .map { metrics -> + AchievementEvaluator.evaluate( + AchievementInputs( + totalHabitCompletions = metrics.habit.totalCompletions, + bestStreak = metrics.habit.bestStreak, + perfectDays = metrics.habit.perfectDays, + mondayPerfectDays = metrics.habit.mondayPerfectDays, + perfectWeeks = metrics.habit.perfectWeeks, + nightOwlTasks = metrics.tasks.nightOwlTasks, + bestSundayTasks = metrics.tasks.bestSundayTasks + ) + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) +} diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt index 87ba5069..72d6d591 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt @@ -39,6 +39,7 @@ fun SettingsScreen( onNavigateToAppearance: () -> Unit = {}, onNavigateToBottomNavConfig: () -> Unit = {}, onNavigateToCategories: () -> Unit = {}, + onNavigateToAchievements: () -> Unit = {}, onNavigateToIntegrations: () -> Unit = {}, onNavigateToAbout: () -> Unit = {}, notificationsEnabled: Boolean = true, @@ -50,6 +51,7 @@ fun SettingsScreen( SettingsItemData.Appearance -> onNavigateToAppearance() SettingsItemData.BottomNavConfig -> onNavigateToBottomNavConfig() SettingsItemData.Categories -> onNavigateToCategories() + SettingsItemData.Achievements -> onNavigateToAchievements() SettingsItemData.Integrations -> onNavigateToIntegrations() SettingsItemData.About -> onNavigateToAbout() } @@ -98,6 +100,7 @@ fun SettingsScreen( SettingsItemData.Notifications, SettingsItemData.Appearance, SettingsItemData.Categories, + SettingsItemData.Achievements, SettingsItemData.BottomNavConfig ) SettingsGroup( @@ -163,6 +166,7 @@ private sealed interface SettingsItemData { data object Appearance : SettingsItemData data object BottomNavConfig : SettingsItemData data object Categories : SettingsItemData + data object Achievements : SettingsItemData data object Integrations : SettingsItemData data object About : SettingsItemData } @@ -246,6 +250,24 @@ private fun SettingsGroup( ) } + is SettingsItemData.Achievements -> { + SettingsNavigationItem( + icon = { + Icon( + imageVector = ImageVector.Companion.vectorResource( + R.drawable.analytics_24px + ), + contentDescription = stringResource(R.string.achievements_title) + ) + }, + headline = stringResource(R.string.achievements_title), + supporting = stringResource(R.string.achievements_settings_description), + shape = shape, + colors = itemColors, + onClick = onClick + ) + } + is SettingsItemData.BottomNavConfig -> { SettingsNavigationItem( icon = { diff --git a/feature/settings/src/main/res/drawable/award_star_24px.xml b/feature/settings/src/main/res/drawable/award_star_24px.xml new file mode 100644 index 00000000..4b117647 --- /dev/null +++ b/feature/settings/src/main/res/drawable/award_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/settings/src/main/res/drawable/check_circle_24px.xml b/feature/settings/src/main/res/drawable/check_circle_24px.xml new file mode 100644 index 00000000..624d17bf --- /dev/null +++ b/feature/settings/src/main/res/drawable/check_circle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/settings/src/main/res/drawable/mode_heat_24px.xml b/feature/settings/src/main/res/drawable/mode_heat_24px.xml new file mode 100644 index 00000000..b59abcc1 --- /dev/null +++ b/feature/settings/src/main/res/drawable/mode_heat_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/settings/src/main/res/drawable/owl_24px.xml b/feature/settings/src/main/res/drawable/owl_24px.xml new file mode 100644 index 00000000..6022fabc --- /dev/null +++ b/feature/settings/src/main/res/drawable/owl_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/settings/src/main/res/drawable/star_shine_24px.xml b/feature/settings/src/main/res/drawable/star_shine_24px.xml new file mode 100644 index 00000000..85a0a7ba --- /dev/null +++ b/feature/settings/src/main/res/drawable/star_shine_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/settings/src/main/res/drawable/view_week_24px.xml b/feature/settings/src/main/res/drawable/view_week_24px.xml new file mode 100644 index 00000000..27094229 --- /dev/null +++ b/feature/settings/src/main/res/drawable/view_week_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/settings/src/main/res/values-en/strings.xml b/feature/settings/src/main/res/values-en/strings.xml index 2c6c7a2a..267b0fde 100644 --- a/feature/settings/src/main/res/values-en/strings.xml +++ b/feature/settings/src/main/res/values-en/strings.xml @@ -89,4 +89,28 @@ Rename General list Reset Rename category + Achievements + Your milestones and progress + You\'ve unlocked %1$d of %2$d levels + %1$d to go for «%2$s» + of %1$d + Level %1$d / %2$d + %1$d / %2$d + Max level + Perfect day + Complete every habit scheduled on a single day + On a streak + Best streak of any habit + Unstoppable + Total habit completions + Night owl + Complete a task between 0:00 and 5:00 + Flawless monday + Perfect days that fall on a Monday + Productive sunday + Tasks completed on a single Sunday + Flawless week + A full week with every scheduled habit completed + Collector + Unlock levels from the other milestones \ No newline at end of file diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 94d13fd5..4e1c655d 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -89,4 +89,28 @@ Renombrar lista General Restablecer Renombrar categoría + Logros + Tus hitos y tu progreso + Has desbloqueado %1$d de %2$d niveles + Te quedan %1$d para «%2$s» + de %1$d + Nivel %1$d / %2$d + %1$d / %2$d + Nivel máximo + Día perfecto + Completa todos los hábitos programados en un mismo día + En racha + Tu mejor racha en cualquier hábito + Imparable + Completaciones de hábito acumuladas + Búho nocturno + Completa una tarea entre las 0:00 y las 5:00 + Lunes impecable + Días perfectos que caen en lunes + Domingo productivo + Tareas completadas en un mismo domingo + Semana impecable + Una semana completa con todos los hábitos programados + Coleccionista + Desbloquea niveles del resto de logros \ No newline at end of file diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 2c6c7a2a..267b0fde 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -89,4 +89,28 @@ Rename General list Reset Rename category + Achievements + Your milestones and progress + You\'ve unlocked %1$d of %2$d levels + %1$d to go for «%2$s» + of %1$d + Level %1$d / %2$d + %1$d / %2$d + Max level + Perfect day + Complete every habit scheduled on a single day + On a streak + Best streak of any habit + Unstoppable + Total habit completions + Night owl + Complete a task between 0:00 and 5:00 + Flawless monday + Perfect days that fall on a Monday + Productive sunday + Tasks completed on a single Sunday + Flawless week + A full week with every scheduled habit completed + Collector + Unlock levels from the other milestones \ No newline at end of file From 28826f6e7bca2cd0c8b90ead60a92526311ed867 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Fri, 28 Aug 2026 16:44:32 +0200 Subject: [PATCH 12/14] fix: AchievementsScreen.kt not charging The applied shadow caused some problems with low-end devices. (Especially the emulator, I know that the emulator problem could be something that is not seen in real life phones, but I think this isn't necessary and I don't want to risk anything). --- .../feature/settings/AchievementsScreen.kt | 20 +++++-------------- .../feature/settings/SettingsScreen.kt | 2 +- .../res/drawable/workspace_premium_24px.xml | 10 ++++++++++ 3 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 feature/settings/src/main/res/drawable/workspace_premium_24px.xml diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt index 635fa0a9..0a3af780 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton @@ -44,10 +44,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -242,10 +242,12 @@ private fun HeroSummary(achievements: List) { } Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) { - CircularWavyProgressIndicator( + CircularProgressIndicator( progress = { ringFraction }, color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f), + strokeCap = StrokeCap.Round, + gapSize = 0.dp, modifier = Modifier.size(84.dp) ) Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -310,18 +312,6 @@ private fun AchievementCard(progress: AchievementProgress) { scaleY = s alpha = if (locked) 0.55f else 1f } - .run { - // Soft colored glow on the top tier. - if (tier == totalTiers && !locked) { - shadow( - elevation = 10.dp, - shape = cookieShape, - clip = false, - ambientColor = accent, - spotColor = accent - ) - } else this - } .background(color = accent, shape = cookieShape) ) { if (!locked) { diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt index 72d6d591..5b2d51ed 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt @@ -255,7 +255,7 @@ private fun SettingsGroup( icon = { Icon( imageVector = ImageVector.Companion.vectorResource( - R.drawable.analytics_24px + R.drawable.workspace_premium_24px ), contentDescription = stringResource(R.string.achievements_title) ) diff --git a/feature/settings/src/main/res/drawable/workspace_premium_24px.xml b/feature/settings/src/main/res/drawable/workspace_premium_24px.xml new file mode 100644 index 00000000..f071fc6c --- /dev/null +++ b/feature/settings/src/main/res/drawable/workspace_premium_24px.xml @@ -0,0 +1,10 @@ + + + From 0a5e64ac2c9d8bed0377255c6ecf3004bb482728 Mon Sep 17 00:00:00 2001 From: Markel15 Date: Fri, 28 Aug 2026 17:11:23 +0200 Subject: [PATCH 13/14] feat: improve visually AchievementsScreen.kt Delete the glossy effect, fixed the title to the top (centered). Finally, make the bottom navigation bar transparent in that screen. --- .../feature/settings/AchievementsScreen.kt | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt index 0a3af780..d9e67246 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -30,11 +32,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,13 +44,11 @@ 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight @@ -70,15 +68,14 @@ fun AchievementsScreen( viewModel: AchievementsViewModel = hiltViewModel() ) { val achievements by viewModel.achievements.collectAsStateWithLifecycle() - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = Modifier.fillMaxSize(), + // To avoid big gaps of surface at the top & bottom (same as the + // main tabs): the grid scrolls behind the gesture navigation bar. + contentWindowInsets = WindowInsets(0.dp), topBar = { - MediumFlexibleTopAppBar( - scrollBehavior = scrollBehavior, + CenterAlignedTopAppBar( title = { Text( text = stringResource(R.string.achievements_title), @@ -93,10 +90,7 @@ fun AchievementsScreen( contentDescription = null ) } - }, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface - ) + } ) } ) { innerPadding -> @@ -181,9 +175,7 @@ private fun shapeForTier(tierReached: Int, totalTiers: Int): Shape = when { } /** - * Expressive summary: wavy ring with the unlocked tier count, plus the - * closest achievable next level — information that stays useful no - * matter how often the screen is opened (no motivational filler). + * Summary: ring with the unlocked tier count, plus the closest achievable next level */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -314,20 +306,6 @@ private fun AchievementCard(progress: AchievementProgress) { } .background(color = accent, shape = cookieShape) ) { - if (!locked) { - // Gloss on the top half so the cookie reads as "lit". - Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.30f), - 0.45f to Color.Transparent - ), - shape = cookieShape - ) - ) - } Icon( imageVector = ImageVector.vectorResource(visuals.iconRes), contentDescription = stringResource(visuals.nameRes), From 7245fad0d2c4d85c0796c978636bd57f8643ac4c Mon Sep 17 00:00:00 2001 From: Markel15 Date: Fri, 28 Aug 2026 22:53:18 +0200 Subject: [PATCH 14/14] feat: improve semantics of the AchievementsScreen.kt Improved the description of the achievements to make them clearer. Reordered them based on the progress. Also improved the shapes. --- .../feature/settings/AchievementsScreen.kt | 56 +++++++++++-------- .../feature/settings/AchievementsViewModel.kt | 17 +++++- .../feature/settings/SettingsScreen.kt | 6 +- .../src/main/res/values-en/strings.xml | 16 +++--- .../src/main/res/values-es/strings.xml | 10 ++-- .../settings/src/main/res/values/strings.xml | 16 +++--- 6 files changed, 75 insertions(+), 46 deletions(-) diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt index d9e67246..990e5625 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -111,7 +112,7 @@ fun AchievementsScreen( } items(achievements, key = { it.definition.id }) { progress -> - AchievementCard(progress) + AchievementCard(progress, modifier = Modifier.animateItem()) } } } @@ -163,14 +164,14 @@ private fun visualsFor(id: AchievementId): AchievementVisuals = when (id) { @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun shapeForTier(tierReached: Int, totalTiers: Int): Shape = when { - totalTiers == 1 -> if (tierReached >= 1) MaterialShapes.Cookie12Sided.toShape() + totalTiers == 1 -> if (tierReached >= 1) MaterialShapes.SoftBurst.toShape() else MaterialShapes.Cookie4Sided.toShape() else -> when (tierReached) { - 0 -> MaterialShapes.Cookie4Sided.toShape() - 1 -> MaterialShapes.Cookie6Sided.toShape() + 0 -> MaterialShapes.Square.toShape() + 1 -> MaterialShapes.Cookie4Sided.toShape() 2 -> MaterialShapes.Cookie9Sided.toShape() - else -> MaterialShapes.Cookie12Sided.toShape() + else -> MaterialShapes.SoftBurst.toShape() } } @@ -261,28 +262,39 @@ private fun HeroSummary(achievements: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun AchievementCard(progress: AchievementProgress) { +private fun AchievementCard(progress: AchievementProgress, modifier: Modifier = Modifier) { val visuals = visualsFor(progress.definition.id) val tier = progress.tierReached val totalTiers = progress.definition.tiers.size val cookieShape = shapeForTier(tier, totalTiers) - // The accent mirrors the highest unlocked tier; locked stays dim. - val accent: Color = when (tier) { - 0 -> MaterialTheme.colorScheme.onSurfaceVariant - 1 -> MaterialTheme.colorScheme.primary - 2 -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.tertiary + // Level semantics: a single "primary" hue that fills up as the tier + // grows — level 3 (max) is always the solid one, so the rank reads + // at a glance with any dynamic color scheme. + val locked = tier == 0 + val maxed = progress.maxed + val cookieColor: Color = when { + locked -> MaterialTheme.colorScheme.onSurfaceVariant + maxed -> MaterialTheme.colorScheme.primary + tier == 1 -> MaterialTheme.colorScheme.primaryContainer + else -> lerp( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.primary, + 0.25f + ) } - val onAccent: Color = when (tier) { - 0 -> MaterialTheme.colorScheme.surface - 1 -> MaterialTheme.colorScheme.onPrimary - 2 -> MaterialTheme.colorScheme.onSecondary - else -> MaterialTheme.colorScheme.onTertiary + val onCookie: Color = when { + locked -> MaterialTheme.colorScheme.surface + maxed -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.primary } - val locked = tier == 0 + val accent: Color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.primary + val onAccent: Color = if (locked) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.onPrimary Card( + modifier = modifier, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ) @@ -304,12 +316,12 @@ private fun AchievementCard(progress: AchievementProgress) { scaleY = s alpha = if (locked) 0.55f else 1f } - .background(color = accent, shape = cookieShape) + .background(color = cookieColor, shape = cookieShape) ) { Icon( imageVector = ImageVector.vectorResource(visuals.iconRes), contentDescription = stringResource(visuals.nameRes), - tint = onAccent, + tint = onCookie, modifier = Modifier.size(30.dp) ) } @@ -336,11 +348,11 @@ private fun AchievementCard(progress: AchievementProgress) { style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = 0.65f) else accent, + .copy(alpha = 0.65f) else onCookie, modifier = Modifier .background( color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = 0.10f) else accent.copy(alpha = 0.14f), + .copy(alpha = 0.10f) else cookieColor, shape = CircleShape ) .padding(horizontal = 9.dp, vertical = 2.dp) diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt index 03540aee..16065a67 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt @@ -30,7 +30,22 @@ class AchievementsViewModel @Inject constructor( nightOwlTasks = metrics.tasks.nightOwlTasks, bestSundayTasks = metrics.tasks.bestSundayTasks ) - ) + ).sortedForDisplay() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) } + +/** + * Display order: a level ladder. Locked achievements (level 0) sit on + * top, then level 1, level 2, and maxed ones sink to the bottom. + * Within the same level the least filled bar shows first, so the + * screen matches what the bars show: what needs the most work stays + * on top, nearly-done cards start sinking. + */ +private fun List.sortedForDisplay(): List = + sortedWith( + compareBy { it.maxed } + .thenBy { it.tierReached } + .thenBy { p -> p.nextTier?.let { p.current.toFloat() / it } ?: 1f } + .thenBy { it.definition.id.ordinal } + ) diff --git a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt index 5b2d51ed..c78e656a 100644 --- a/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/SettingsScreen.kt @@ -100,7 +100,6 @@ fun SettingsScreen( SettingsItemData.Notifications, SettingsItemData.Appearance, SettingsItemData.Categories, - SettingsItemData.Achievements, SettingsItemData.BottomNavConfig ) SettingsGroup( @@ -133,7 +132,10 @@ fun SettingsScreen( SettingsGroupLabel(stringResource(R.string.settings_info)) } item { - val infoItems = listOf(SettingsItemData.About) + val infoItems = listOf( + SettingsItemData.Achievements, + SettingsItemData.About + ) SettingsGroup( items = infoItems, appVersion = appVersion, diff --git a/feature/settings/src/main/res/values-en/strings.xml b/feature/settings/src/main/res/values-en/strings.xml index 267b0fde..a4885d5a 100644 --- a/feature/settings/src/main/res/values-en/strings.xml +++ b/feature/settings/src/main/res/values-en/strings.xml @@ -100,17 +100,17 @@ Perfect day Complete every habit scheduled on a single day On a streak - Best streak of any habit + Keep a habit alive: never miss its scheduled days Unstoppable - Total habit completions + Complete habits over and over: every completion counts Night owl Complete a task between 0:00 and 5:00 - Flawless monday - Perfect days that fall on a Monday - Productive sunday - Tasks completed on a single Sunday + Flawless Monday + Complete every habit scheduled on a Monday + Productive Sunday + Complete several tasks on the same Sunday Flawless week - A full week with every scheduled habit completed + Complete every scheduled habit for a whole week Collector - Unlock levels from the other milestones + Unlock levels in the other achievements \ No newline at end of file diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 4e1c655d..c2f0bcae 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -100,17 +100,17 @@ Día perfecto Completa todos los hábitos programados en un mismo día En racha - Tu mejor racha en cualquier hábito + No falles ningún día programado de un mismo hábito Imparable - Completaciones de hábito acumuladas + Completa hábitos una y otra vez: cada una suma Búho nocturno Completa una tarea entre las 0:00 y las 5:00 Lunes impecable - Días perfectos que caen en lunes + Completa todos los hábitos programados un lunes Domingo productivo - Tareas completadas en un mismo domingo + Completa varias tareas en un mismo domingo Semana impecable - Una semana completa con todos los hábitos programados + Completa todos los hábitos programados durante una semana entera Coleccionista Desbloquea niveles del resto de logros \ No newline at end of file diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 267b0fde..a4885d5a 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -100,17 +100,17 @@ Perfect day Complete every habit scheduled on a single day On a streak - Best streak of any habit + Keep a habit alive: never miss its scheduled days Unstoppable - Total habit completions + Complete habits over and over: every completion counts Night owl Complete a task between 0:00 and 5:00 - Flawless monday - Perfect days that fall on a Monday - Productive sunday - Tasks completed on a single Sunday + Flawless Monday + Complete every habit scheduled on a Monday + Productive Sunday + Complete several tasks on the same Sunday Flawless week - A full week with every scheduled habit completed + Complete every scheduled habit for a whole week Collector - Unlock levels from the other milestones + Unlock levels in the other achievements \ No newline at end of file