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). 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/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..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 @@ -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 @@ -43,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 -> @@ -61,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, @@ -69,6 +73,7 @@ class HabitRepositoryImpl @Inject constructor( value = value ) ) + } override suspend fun deleteNumericEntry(habitId: Int, date: LocalDate) = dao.deleteNumericEntry(habitId, date.toEpochDay()) @@ -84,13 +89,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 +103,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/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/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..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 @@ -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,15 +10,28 @@ 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() ) +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/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/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..d3f7aab2 --- /dev/null +++ b/core/domain/src/main/java/com/markel/flowstate/core/domain/HabitStreakCalculator.kt @@ -0,0 +1,104 @@ +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. + * + * 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() + ): Int { + if (completedDates.isEmpty() || scheduledDays.isEmpty()) return 0 + + val validDates = completedDates.asSequence() + .filter { it.dayOfWeek in scheduledDays } + .filter { !it.isAfter(today) } + .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 { + previousScheduledDate(today, scheduledDays) + } + + var streak = 0 + while (expected in validDates) { + streak++ + expected = previousScheduledDate(expected, scheduledDays) + } + return streak + } + + fun best( + completedDates: Collection, + scheduledDays: Set + ): Int { + if (completedDates.isEmpty() || scheduledDays.isEmpty()) return 0 + + val validDates = completedDates.asSequence() + .filter { it.dayOfWeek in scheduledDays } + .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/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/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..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 @@ -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,12 @@ 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 + ) HabitWithStatus( habit = habit, isCompletedToday = isCompletedToday, @@ -97,7 +102,15 @@ 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 + ) HabitWithStatus( habit = habit, @@ -110,40 +123,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/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/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/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/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..a4c00ac7 --- /dev/null +++ b/core/domain/src/test/java/com/markel/flowstate/core/domain/HabitStreakCalculatorTest.kt @@ -0,0 +1,221 @@ +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_countsBackfilledEntriesCompletedBeforeCreation() { + assertEquals( + 2, + HabitStreakCalculator.current( + completedDates = setOf(monday.minusDays(1), monday), + scheduledDays = everyDay, + today = monday + ) + ) + } + + @Test + fun best_countsBackfilledEntriesCompletedBeforeCreation() { + assertEquals( + 3, + HabitStreakCalculator.best( + completedDates = setOf(monday.minusDays(2), monday.minusDays(1), monday), + scheduledDays = everyDay + ) + ) + } + + @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/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/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 d9c97892..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 kotlinx.coroutines.flow.first import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class ToggleHabitAction : ActionCallback { @@ -32,6 +39,14 @@ class ToggleHabitAction : ActionCallback { ) val repository = entryPoint.habitRepository() val habit = repository.getHabitById(habitId) ?: return + val today = LocalDate.now() + + // 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") { @@ -44,9 +59,31 @@ 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) } + + 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. 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..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 @@ -25,6 +26,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 @@ -61,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 @@ -141,9 +145,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 +158,8 @@ class HabitViewModel @Inject constructor( habitType = habitType, unit = unit, targetValue = targetValue, - step = step + step = step, + scheduledDays = scheduledDays ) ) _showAddDialog.value = false @@ -170,9 +176,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 +188,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..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,17 +17,20 @@ 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 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 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 @@ -37,6 +40,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 @@ -53,11 +58,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 +99,8 @@ fun AddHabitSheet( habitType: HabitType, unit: String?, targetValue: Float?, - step: Float + step: Float, + scheduledDays: Set ) -> Unit, initialName: String = "", initialIcon: String = "none", @@ -96,7 +108,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 +121,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() @@ -176,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 }, @@ -277,6 +305,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), @@ -300,11 +390,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 +418,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..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 @@ -38,13 +39,19 @@ 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 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 @@ -94,9 +101,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 } ) @@ -128,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( @@ -182,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 @@ -223,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 @@ -241,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 } @@ -253,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 220ab124..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 @@ -44,7 +45,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 @@ -81,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 @@ -89,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( @@ -140,16 +151,17 @@ 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 } ) } // Input sheet (goal-ring + hold-to-repeat steppers) - if (showInputDialog) { + if (showInputDialog && selectedDateIsScheduled) { val currentValueForDialog = currentWeekValues.getOrNull(selectedDayIndex) NumericInputSheet( habitName = habit.name, @@ -192,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)) { @@ -214,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 { @@ -286,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( @@ -296,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) ) } @@ -318,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 @@ -385,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 @@ -436,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/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 3caa3747..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,8 @@ 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 import com.markel.flowstate.core.domain.usecase.habits.GetNumericEntriesUseCase @@ -59,41 +61,57 @@ 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 + ), + bestStreak = HabitStreakCalculator.best( + completedDates = entries, + scheduledDays = habit.scheduledDays + ), 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 + ), + bestStreak = HabitStreakCalculator.best( + completedDates = completedDates, + scheduledDays = habit.scheduledDays + ) ) } } @@ -179,28 +197,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 @@ -290,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()) @@ -362,47 +364,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/main/res/values-en/strings.xml b/feature/habits/src/main/res/values-en/strings.xml index 550ba041..bd0a1c15 100644 --- a/feature/habits/src/main/res/values-en/strings.xml +++ b/feature/habits/src/main/res/values-en/strings.xml @@ -7,6 +7,9 @@ Goal reached! Name 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 1ffc8c53..cd22fd5e 100644 --- a/feature/habits/src/main/res/values-es/strings.xml +++ b/feature/habits/src/main/res/values-es/strings.xml @@ -7,6 +7,9 @@ Β‘Objetivo cumplido! Nombre 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 5d273b91..f0a8f01e 100644 --- a/feature/habits/src/main/res/values/strings.xml +++ b/feature/habits/src/main/res/values/strings.xml @@ -6,6 +6,9 @@ Goal reached! Name 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/HabitDetailViewModelTest.kt b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitDetailViewModelTest.kt index f2375feb..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 @@ -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 @@ -287,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 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..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 @@ -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 { @@ -58,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) = @@ -169,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 @@ -260,11 +322,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 +417,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 +427,8 @@ class HabitViewModelTest { newColorArgb = 0xFF00FF00.toInt(), newUnit = "Pages", newTargetValue = 20f, - newStep = 5f + newStep = 5f, + newScheduledDays = newScheduledDays ) // THEN @@ -330,7 +440,8 @@ class HabitViewModelTest { habit.colorArgb == 0xFF00FF00.toInt() && habit.unit == "Pages" && habit.targetValue == 20f && - habit.step == 5f + habit.step == 5f && + habit.scheduledDays == newScheduledDays }) } } @@ -355,6 +466,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 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..990e5625 --- /dev/null +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsScreen.kt @@ -0,0 +1,455 @@ +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.WindowInsets +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.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +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.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.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 +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() + + Scaffold( + 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 = { + CenterAlignedTopAppBar( + 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 + ) + } + } + ) + } + ) { 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, modifier = Modifier.animateItem()) + } + } + } +} + +/** 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.SoftBurst.toShape() + else MaterialShapes.Cookie4Sided.toShape() + + else -> when (tierReached) { + 0 -> MaterialShapes.Square.toShape() + 1 -> MaterialShapes.Cookie4Sided.toShape() + 2 -> MaterialShapes.Cookie9Sided.toShape() + else -> MaterialShapes.SoftBurst.toShape() + } +} + +/** + * Summary: ring with the unlocked tier count, plus the closest achievable next level + */ +@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)) { + 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) { + 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, modifier: Modifier = Modifier) { + val visuals = visualsFor(progress.definition.id) + val tier = progress.tierReached + val totalTiers = progress.definition.tiers.size + val cookieShape = shapeForTier(tier, totalTiers) + + // 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 onCookie: Color = when { + locked -> MaterialTheme.colorScheme.surface + maxed -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.primary + } + 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 + ) + ) { + 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 + } + .background(color = cookieColor, shape = cookieShape) + ) { + Icon( + imageVector = ImageVector.vectorResource(visuals.iconRes), + contentDescription = stringResource(visuals.nameRes), + tint = onCookie, + 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 onCookie, + modifier = Modifier + .background( + color = if (locked) MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.10f) else cookieColor, + 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..16065a67 --- /dev/null +++ b/feature/settings/src/main/java/com/markel/flowstate/feature/settings/AchievementsViewModel.kt @@ -0,0 +1,51 @@ +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 + ) + ).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 87ba5069..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 @@ -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() } @@ -130,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, @@ -163,6 +168,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 +252,24 @@ private fun SettingsGroup( ) } + is SettingsItemData.Achievements -> { + SettingsNavigationItem( + icon = { + Icon( + imageVector = ImageVector.Companion.vectorResource( + R.drawable.workspace_premium_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/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 @@ + + + diff --git a/feature/settings/src/main/res/values-en/strings.xml b/feature/settings/src/main/res/values-en/strings.xml index 2c6c7a2a..a4885d5a 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 + Keep a habit alive: never miss its scheduled days + Unstoppable + Complete habits over and over: every completion counts + Night owl + Complete a task between 0:00 and 5:00 + Flawless Monday + Complete every habit scheduled on a Monday + Productive Sunday + Complete several tasks on the same Sunday + Flawless week + Complete every scheduled habit for a whole week + Collector + 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 94d13fd5..c2f0bcae 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 + No falles ningΓΊn dΓ­a programado de un mismo hΓ‘bito + Imparable + 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 + Completa todos los hΓ‘bitos programados un lunes + Domingo productivo + Completa varias tareas en un mismo domingo + Semana impecable + 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 2c6c7a2a..a4885d5a 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 + Keep a habit alive: never miss its scheduled days + Unstoppable + Complete habits over and over: every completion counts + Night owl + Complete a task between 0:00 and 5:00 + Flawless Monday + Complete every habit scheduled on a Monday + Productive Sunday + Complete several tasks on the same Sunday + Flawless week + Complete every scheduled habit for a whole week + Collector + Unlock levels in the other achievements \ No newline at end of file