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.**
-[](https://kotlinlang.org)
-[](fhttps://developer.android.com/jetpack/compose)
-[](https://developer.android.com/training/data-storage/room)
-[](https://dagger.dev/hilt/)
+No accounts, no ads, no tracking, no servers. Your data never leaves your phone.
+
+[](https://github.com/Markel15/FlowState/releases/latest)
+[](LICENSE)
+[-3DDC84?logo=android&logoColor=white)](https://www.android.com)
+
+## Download
+**Get the latest APK β no store, no sign-up:**
+
+[](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
- 




+ 




## 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 |
+
+[](https://kotlinlang.org)
+[](https://developer.android.com/jetpack/compose)
+[](https://developer.android.com/training/data-storage/room)
+[](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