From a21d63eb109066205142ca656f2cb6c483716297 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:21:17 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(ironmind):=20Stage=201=20=E2=80=94=20R?= =?UTF-8?q?oom=20data=20layer=20&=20Clean=20Architecture=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold the IronMind gym-tracking Android app (Kotlin, Compose, Hilt, Room) under IronMind/. This first stage delivers the persistence layer and the Clean Architecture package structure. Data layer (Room): - Entities: ExerciseEntity, RoutineEntity (RoutineSplit: Push/Pull/Legs…), RoutineExerciseCrossRef (many-to-many + prescription), WorkoutSessionEntity, SetLogEntity (weight, reps, RPE, and a free-form notes field for supplements / energy levels for later AI use). - Relations: RoutineWithExercises, SessionWithSets. - WorkoutDao: Flow-based reads, suspend writes, @Transaction relation queries, "last set" lookup for progression. - Enum TypeConverters, DB definition with schema export, seed catalog. Architecture & wiring: - domain (pure models + WorkoutRepository contract), data (mappers + WorkoutRepositoryImpl), Hilt modules (Database, Repository, Coroutines). - Compose design tokens: pure-black surface, gold/cyan accents, glassmorphism. - Minimal Hilt Application + themed MainActivity placeholder so the app runs. - Unit test (mappers) + instrumented Room DAO tests. Build: Gradle 8.11.1 wrapper, version catalog, AGP 8.7.3, Kotlin 2.1.0, KSP, Room 2.6.1, Hilt 2.54, Compose BOM. minSdk 26 / target 35 / JDK 17. Note: verified with static checks only (version catalog parsed, sources reviewed); a full assembleDebug requires the Android SDK, absent in this environment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012YaiFyXHFt67jzX79tgvAx --- IronMind/.gitignore | 42 +++ IronMind/README.md | 80 ++++++ IronMind/app/build.gradle.kts | 104 ++++++++ IronMind/app/proguard-rules.pro | 14 + .../java/com/ironmind/app/WorkoutDaoTest.kt | 104 ++++++++ IronMind/app/src/main/AndroidManifest.xml | 26 ++ .../com/ironmind/app/IronMindApplication.kt | 8 + .../java/com/ironmind/app/MainActivity.kt | 103 +++++++ .../com/ironmind/app/core/util/Constants.kt | 7 + .../app/core/util/DispatcherProvider.kt | 22 ++ .../app/data/local/IronMindDatabase.kt | 34 +++ .../app/data/local/converter/Converters.kt | 34 +++ .../ironmind/app/data/local/dao/WorkoutDao.kt | 133 ++++++++++ .../app/data/local/entity/ExerciseEntity.kt | 21 ++ .../app/data/local/entity/RoutineEntity.kt | 16 ++ .../local/entity/RoutineExerciseCrossRef.kt | 38 +++ .../app/data/local/entity/SetLogEntity.kt | 44 +++ .../data/local/entity/WorkoutSessionEntity.kt | 31 +++ .../local/relation/RoutineWithExercises.kt | 26 ++ .../data/local/relation/SessionWithSets.kt | 18 ++ .../app/data/local/seed/DefaultExercises.kt | 60 +++++ .../ironmind/app/data/mapper/EntityMappers.kt | 117 ++++++++ .../data/repository/WorkoutRepositoryImpl.kt | 132 +++++++++ .../com/ironmind/app/di/CoroutinesModule.kt | 27 ++ .../com/ironmind/app/di/DatabaseModule.kt | 51 ++++ .../java/com/ironmind/app/di/Qualifiers.kt | 8 + .../com/ironmind/app/di/RepositoryModule.kt | 19 ++ .../ironmind/app/domain/model/Equipment.kt | 13 + .../com/ironmind/app/domain/model/Exercise.kt | 15 ++ .../ironmind/app/domain/model/MuscleGroup.kt | 18 ++ .../com/ironmind/app/domain/model/Routine.kt | 14 + .../ironmind/app/domain/model/RoutinePlan.kt | 10 + .../ironmind/app/domain/model/RoutineSplit.kt | 21 ++ .../app/domain/model/SessionDetail.kt | 18 ++ .../com/ironmind/app/domain/model/SetLog.kt | 28 ++ .../app/domain/model/WorkoutSession.kt | 18 ++ .../domain/repository/WorkoutRepository.kt | 59 ++++ .../java/com/ironmind/app/ui/theme/Color.kt | 16 ++ .../java/com/ironmind/app/ui/theme/Theme.kt | 32 +++ .../java/com/ironmind/app/ui/theme/Type.kt | 41 +++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 23 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + IronMind/app/src/main/res/values/colors.xml | 10 + IronMind/app/src/main/res/values/strings.xml | 4 + IronMind/app/src/main/res/values/themes.xml | 14 + .../app/src/main/res/xml/backup_rules.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 10 + .../com/ironmind/app/EntityMappersTest.kt | 44 +++ IronMind/build.gradle.kts | 9 + IronMind/gradle.properties | 15 ++ IronMind/gradle/libs.versions.toml | 87 ++++++ IronMind/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + IronMind/gradlew | 251 ++++++++++++++++++ IronMind/gradlew.bat | 94 +++++++ IronMind/settings.gradle.kts | 24 ++ 58 files changed, 2244 insertions(+) create mode 100644 IronMind/.gitignore create mode 100644 IronMind/README.md create mode 100644 IronMind/app/build.gradle.kts create mode 100644 IronMind/app/proguard-rules.pro create mode 100644 IronMind/app/src/androidTest/java/com/ironmind/app/WorkoutDaoTest.kt create mode 100644 IronMind/app/src/main/AndroidManifest.xml create mode 100644 IronMind/app/src/main/java/com/ironmind/app/IronMindApplication.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/core/util/Constants.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/core/util/DispatcherProvider.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/IronMindDatabase.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/converter/Converters.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/entity/ExerciseEntity.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineEntity.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineExerciseCrossRef.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/entity/SetLogEntity.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/entity/WorkoutSessionEntity.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/relation/RoutineWithExercises.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/relation/SessionWithSets.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/local/seed/DefaultExercises.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/mapper/EntityMappers.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/di/CoroutinesModule.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/di/DatabaseModule.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/di/Qualifiers.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/di/RepositoryModule.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/Equipment.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/Exercise.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/MuscleGroup.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/Routine.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutinePlan.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutineSplit.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/SessionDetail.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/SetLog.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/WorkoutSession.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/theme/Color.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/theme/Type.kt create mode 100644 IronMind/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 IronMind/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 IronMind/app/src/main/res/values/colors.xml create mode 100644 IronMind/app/src/main/res/values/strings.xml create mode 100644 IronMind/app/src/main/res/values/themes.xml create mode 100644 IronMind/app/src/main/res/xml/backup_rules.xml create mode 100644 IronMind/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 IronMind/app/src/test/java/com/ironmind/app/EntityMappersTest.kt create mode 100644 IronMind/build.gradle.kts create mode 100644 IronMind/gradle.properties create mode 100644 IronMind/gradle/libs.versions.toml create mode 100644 IronMind/gradle/wrapper/gradle-wrapper.jar create mode 100644 IronMind/gradle/wrapper/gradle-wrapper.properties create mode 100755 IronMind/gradlew create mode 100644 IronMind/gradlew.bat create mode 100644 IronMind/settings.gradle.kts diff --git a/IronMind/.gitignore b/IronMind/.gitignore new file mode 100644 index 0000000..79be24a --- /dev/null +++ b/IronMind/.gitignore @@ -0,0 +1,42 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (SDK path, etc.) +local.properties + +# Log files +*.log + +# Android Studio / IntelliJ +*.iml +.idea/ +.DS_Store +captures/ +.externalNativeBuild/ +.cxx/ + +# Keystore files +*.jks +*.keystore + +# Kotlin +.kotlin/ diff --git a/IronMind/README.md b/IronMind/README.md new file mode 100644 index 0000000..b510d8c --- /dev/null +++ b/IronMind/README.md @@ -0,0 +1,80 @@ +# IronMind 🏋️‍♂️🧠 + +Premium, **100% offline** gym-tracking app for Android with **on-device AI** (Google AI Edge / +MediaPipe LLM Inference). Built with a strict, modern Android stack. + +> **Note on the app name:** `IronMind` (package `com.ironmind.app`) is a working title chosen so +> the project could be scaffolded end-to-end. It is trivial to rename — just say the word and it +> gets updated before Stage 2. + +## Tech stack + +| Layer | Choice | +|------------------|---------------------------------------------------------------| +| Language | Kotlin | +| UI | Jetpack Compose (Material 3) | +| Architecture | Clean Architecture + MVVM + Hilt (DI) | +| Persistence | Room (SQLite) with Flow + Coroutines | +| On-device AI | Google AI Edge — MediaPipe LLM Inference API (streaming) — *Stage 3* | + +## Design language + +Pure-black (`#000000`) surface, **glassmorphism** cards with subtle glowing edges, and dynamic +accents in **gold `#FFD700`** and **cyan `#00FFFF`**. Tokens live in +`ui/theme/` (`Color.kt`, `Theme.kt`, `Type.kt`). + +## Roadmap + +- **Stage 1 — Data & persistence (this commit).** Package structure, Room entities, relations, + DAO, DI wiring, seed catalog. ✅ +- **Stage 2 — Domain & presentation.** Use cases, MVVM ViewModels, state, navigation. +- **Stage 3 — UI + on-device AI.** Full glassmorphism screens and the streaming MediaPipe LLM. + +## Project structure (Clean Architecture) + +``` +com.ironmind.app +├── core/util # DispatcherProvider, Constants — cross-cutting helpers +├── domain # Pure Kotlin — no Android/Room dependencies +│ ├── model # Exercise, Routine, WorkoutSession, SetLog, enums, aggregates +│ └── repository # WorkoutRepository (the contract the app depends on) +├── data # Implements the domain contracts +│ ├── local +│ │ ├── entity # @Entity: Exercise, Routine, RoutineExerciseCrossRef, Session, SetLog +│ │ ├── relation # RoutineWithExercises, SessionWithSets +│ │ ├── converter # Enum <-> String TypeConverters +│ │ ├── dao # WorkoutDao (Flow reads, suspend writes, @Transaction relations) +│ │ ├── seed # DefaultExercises — starter catalog on first launch +│ │ └── IronMindDatabase.kt +│ ├── mapper # entity <-> domain mappers +│ └── repository # WorkoutRepositoryImpl +├── di # Hilt modules: Database, Repository, Coroutines +└── ui/theme # Compose design system (black + gold + cyan, glassmorphism) +``` + +## Data model + +- **ExerciseEntity** — catalog of exercises (name, muscle group, equipment, custom flag). +- **RoutineEntity** — a training day with a `RoutineSplit` (PUSH / PULL / LEGS / UPPER / LOWER / …). +- **RoutineExerciseCrossRef** — many-to-many junction with per-routine prescription + (target sets / reps / rest) and ordering. +- **WorkoutSessionEntity** — a performed session, optionally linked to a routine. +- **SetLogEntity** — a single logged set: `weightKg`, `reps`, `rpe`, and a free-form `notes` + field for supplements (e.g. *Optimum Nutrition Gold Standard 100% Whey*), energy levels, etc. — + context the on-device AI will summarize in Stage 3. + +Relations: **`RoutineWithExercises`** and **`SessionWithSets`**. + +## Building + +Requires Android Studio (Ladybug+) or the Android SDK with `ANDROID_HOME` set. + +```bash +cd IronMind +./gradlew assembleDebug # build the APK +./gradlew testDebugUnitTest # JVM unit tests (mappers) +./gradlew connectedDebugAndroidTest # instrumented Room DAO tests (device/emulator) +``` + +- **Min SDK:** 26 · **Target/Compile SDK:** 35 · **JDK:** 17 +- Room schemas are exported to `app/schemas/` for future migrations. diff --git a/IronMind/app/build.gradle.kts b/IronMind/app/build.gradle.kts new file mode 100644 index 0000000..f0c6623 --- /dev/null +++ b/IronMind/app/build.gradle.kts @@ -0,0 +1,104 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.ironmind.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.ironmind.app" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + } + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +// Room schema export — keeps a versioned history of the DB for migrations & tests. +// (KSP is a top-level extension, configured outside the `android { }` block.) +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.generateKotlin", "true") +} + +dependencies { + // Core / lifecycle + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.androidx.ui.tooling) + + // Room (persistence) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // Hilt (dependency injection) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // On-device LLM — enabled in Stage 3 (Google AI Edge / MediaPipe LLM Inference API). + // implementation(libs.mediapipe.tasks.genai) + + // Unit testing + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + // Instrumented testing (Room DAO tests run on-device/emulator) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/IronMind/app/proguard-rules.pro b/IronMind/app/proguard-rules.pro new file mode 100644 index 0000000..73a59d8 --- /dev/null +++ b/IronMind/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Default optimized ProGuard rules are pulled in from proguard-android-optimize.txt. + +# Room generates code that reflects over entities; keep annotations intact. +-keep class androidx.room.** { *; } +-keepclassmembers class * { + @androidx.room.* ; +} + +# Keep Hilt-generated components. +-keep class dagger.hilt.** { *; } +-keep class * extends dagger.hilt.internal.GeneratedComponent { *; } + +# On-device LLM native bindings (used from Stage 3). +-keep class com.google.mediapipe.** { *; } diff --git a/IronMind/app/src/androidTest/java/com/ironmind/app/WorkoutDaoTest.kt b/IronMind/app/src/androidTest/java/com/ironmind/app/WorkoutDaoTest.kt new file mode 100644 index 0000000..9da3641 --- /dev/null +++ b/IronMind/app/src/androidTest/java/com/ironmind/app/WorkoutDaoTest.kt @@ -0,0 +1,104 @@ +package com.ironmind.app + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ironmind.app.data.local.IronMindDatabase +import com.ironmind.app.data.local.dao.WorkoutDao +import com.ironmind.app.data.local.entity.ExerciseEntity +import com.ironmind.app.data.local.entity.RoutineEntity +import com.ironmind.app.data.local.entity.RoutineExerciseCrossRef +import com.ironmind.app.data.local.entity.SetLogEntity +import com.ironmind.app.data.local.entity.WorkoutSessionEntity +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.RoutineSplit +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [WorkoutDao] — run on a device/emulator against a real (in-memory) + * Room database. Verifies the relations, the type converters, and FK cascade behavior. + */ +@RunWith(AndroidJUnit4::class) +class WorkoutDaoTest { + + private lateinit var db: IronMindDatabase + private lateinit var dao: WorkoutDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, IronMindDatabase::class.java) + .build() + dao = db.workoutDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun routineWithExercises_resolvesLinkedExercisesThroughJunction() = runTest { + val routineId = dao.upsertRoutine(RoutineEntity(name = "Push Day", split = RoutineSplit.PUSH)) + val benchId = dao.upsertExercise( + ExerciseEntity(name = "Bench Press", muscleGroup = MuscleGroup.CHEST, equipment = Equipment.BARBELL), + ) + val ohpId = dao.upsertExercise( + ExerciseEntity(name = "Overhead Press", muscleGroup = MuscleGroup.SHOULDERS, equipment = Equipment.BARBELL), + ) + dao.upsertRoutineExerciseCrossRef(RoutineExerciseCrossRef(routineId, benchId, position = 0)) + dao.upsertRoutineExerciseCrossRef(RoutineExerciseCrossRef(routineId, ohpId, position = 1)) + + val plans = dao.observeRoutinesWithExercises().first() + + assertEquals(1, plans.size) + assertEquals(RoutineSplit.PUSH, plans.first().routine.split) + assertEquals(setOf("Bench Press", "Overhead Press"), plans.first().exercises.map { it.name }.toSet()) + } + + @Test + fun sessionWithSets_returnsAllLoggedSets() = runTest { + val sessionId = dao.insertSession(WorkoutSessionEntity(startedAt = 1_000L, title = "Leg Day")) + val squatId = dao.upsertExercise( + ExerciseEntity(name = "Back Squat", muscleGroup = MuscleGroup.QUADS, equipment = Equipment.BARBELL), + ) + dao.upsertSetLog(SetLogEntity(sessionId = sessionId, exerciseId = squatId, setNumber = 1, weightKg = 100.0, reps = 5)) + dao.upsertSetLog( + SetLogEntity( + sessionId = sessionId, + exerciseId = squatId, + setNumber = 2, + weightKg = 100.0, + reps = 5, + notes = "Felt strong — 30g ON Gold Standard Whey pre-workout", + ), + ) + + val detail = dao.observeSessionWithSets(sessionId).first() + + assertEquals("Leg Day", detail?.session?.title) + assertEquals(2, detail?.sets?.size) + } + + @Test + fun deletingSession_cascadesToSetLogs() = runTest { + val sessionId = dao.insertSession(WorkoutSessionEntity(startedAt = 2_000L)) + val curlId = dao.upsertExercise( + ExerciseEntity(name = "Barbell Curl", muscleGroup = MuscleGroup.BICEPS, equipment = Equipment.BARBELL), + ) + dao.upsertSetLog(SetLogEntity(sessionId = sessionId, exerciseId = curlId, setNumber = 1, weightKg = 30.0, reps = 10)) + + dao.deleteSession(WorkoutSessionEntity(id = sessionId, startedAt = 2_000L)) + + assertNull(dao.getLastSetLogForExercise(curlId)) + } +} diff --git a/IronMind/app/src/main/AndroidManifest.xml b/IronMind/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d719725 --- /dev/null +++ b/IronMind/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/IronMind/app/src/main/java/com/ironmind/app/IronMindApplication.kt b/IronMind/app/src/main/java/com/ironmind/app/IronMindApplication.kt new file mode 100644 index 0000000..d1cd750 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/IronMindApplication.kt @@ -0,0 +1,8 @@ +package com.ironmind.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +/** Application entry point. [HiltAndroidApp] triggers Hilt's code generation and DI graph. */ +@HiltAndroidApp +class IronMindApplication : Application() diff --git a/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt b/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt new file mode 100644 index 0000000..7f1abbb --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt @@ -0,0 +1,103 @@ +package com.ironmind.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.GlassFill +import com.ironmind.app.ui.theme.Gold +import com.ironmind.app.ui.theme.IronMindTheme +import com.ironmind.app.ui.theme.TextMuted +import dagger.hilt.android.AndroidEntryPoint + +/** + * Single-activity host. The real navigation and screens land in Stage 3; for now this shows + * a themed placeholder so the app builds, runs, and previews the design language. + */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + IronMindTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + StageOnePlaceholder() + } + } + } + } +} + +@Composable +private fun StageOnePlaceholder(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + // Frosted "glass" card with a subtle gold→cyan glowing edge. + Column( + modifier = Modifier + .background(GlassFill, RoundedCornerShape(24.dp)) + .border( + width = 1.dp, + brush = Brush.linearGradient( + listOf(Gold.copy(alpha = 0.6f), Cyan.copy(alpha = 0.4f)), + ), + shape = RoundedCornerShape(24.dp), + ) + .padding(horizontal = 28.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "IronMind", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = Gold, + ) + Text( + text = "Etapa 1 · Capa de datos lista", + style = MaterialTheme.typography.titleLarge, + color = Cyan, + ) + Text( + text = "Room · Clean Architecture · Hilt\n100% offline · IA on-device (próximamente)", + style = MaterialTheme.typography.bodyLarge, + color = TextMuted, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun StageOnePlaceholderPreview() { + IronMindTheme { + StageOnePlaceholder() + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/core/util/Constants.kt b/IronMind/app/src/main/java/com/ironmind/app/core/util/Constants.kt new file mode 100644 index 0000000..337a2d8 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/core/util/Constants.kt @@ -0,0 +1,7 @@ +package com.ironmind.app.core.util + +/** App-wide constants. */ +object Constants { + /** Room database file name. Referenced by the backup rules in res/xml. */ + const val DATABASE_NAME = "ironmind.db" +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/core/util/DispatcherProvider.kt b/IronMind/app/src/main/java/com/ironmind/app/core/util/DispatcherProvider.kt new file mode 100644 index 0000000..b9a8da6 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/core/util/DispatcherProvider.kt @@ -0,0 +1,22 @@ +package com.ironmind.app.core.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * Abstraction over coroutine dispatchers so use cases and repositories can be tested + * with a deterministic dispatcher (e.g. [kotlinx.coroutines.test.StandardTestDispatcher]) + * instead of the real [Dispatchers]. + */ +interface DispatcherProvider { + val main: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher +} + +/** Production implementation backed by the real [Dispatchers]. */ +class StandardDispatcherProvider : DispatcherProvider { + override val main: CoroutineDispatcher = Dispatchers.Main + override val io: CoroutineDispatcher = Dispatchers.IO + override val default: CoroutineDispatcher = Dispatchers.Default +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/IronMindDatabase.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/IronMindDatabase.kt new file mode 100644 index 0000000..c05eccd --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/IronMindDatabase.kt @@ -0,0 +1,34 @@ +package com.ironmind.app.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.ironmind.app.data.local.converter.Converters +import com.ironmind.app.data.local.dao.WorkoutDao +import com.ironmind.app.data.local.entity.ExerciseEntity +import com.ironmind.app.data.local.entity.RoutineEntity +import com.ironmind.app.data.local.entity.RoutineExerciseCrossRef +import com.ironmind.app.data.local.entity.SetLogEntity +import com.ironmind.app.data.local.entity.WorkoutSessionEntity + +/** + * The app's single Room database. Fully offline — no network is involved at any point. + * + * Schema history is exported to `app/schemas` (configured in build.gradle.kts) so future + * versions can ship proper migrations and be validated by instrumented tests. + */ +@Database( + entities = [ + ExerciseEntity::class, + RoutineEntity::class, + RoutineExerciseCrossRef::class, + WorkoutSessionEntity::class, + SetLogEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class IronMindDatabase : RoomDatabase() { + abstract fun workoutDao(): WorkoutDao +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/converter/Converters.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/converter/Converters.kt new file mode 100644 index 0000000..ea52a68 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/converter/Converters.kt @@ -0,0 +1,34 @@ +package com.ironmind.app.data.local.converter + +import androidx.room.TypeConverter +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.RoutineSplit + +/** + * Room type converters for the domain enums. Enums are stored by [Enum.name] (a stable, + * human-readable string) rather than ordinal, so reordering an enum never corrupts data. + */ +class Converters { + + @TypeConverter + fun fromMuscleGroup(value: MuscleGroup): String = value.name + + @TypeConverter + fun toMuscleGroup(value: String): MuscleGroup = enumValueOrDefault(value, MuscleGroup.OTHER) + + @TypeConverter + fun fromEquipment(value: Equipment): String = value.name + + @TypeConverter + fun toEquipment(value: String): Equipment = enumValueOrDefault(value, Equipment.OTHER) + + @TypeConverter + fun fromRoutineSplit(value: RoutineSplit): String = value.name + + @TypeConverter + fun toRoutineSplit(value: String): RoutineSplit = enumValueOrDefault(value, RoutineSplit.CUSTOM) + + private inline fun > enumValueOrDefault(value: String, default: T): T = + enumValues().firstOrNull { it.name == value } ?: default +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt new file mode 100644 index 0000000..95b2472 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt @@ -0,0 +1,133 @@ +package com.ironmind.app.data.local.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import androidx.room.Upsert +import com.ironmind.app.data.local.entity.ExerciseEntity +import com.ironmind.app.data.local.entity.RoutineEntity +import com.ironmind.app.data.local.entity.RoutineExerciseCrossRef +import com.ironmind.app.data.local.entity.SetLogEntity +import com.ironmind.app.data.local.entity.WorkoutSessionEntity +import com.ironmind.app.data.local.relation.RoutineWithExercises +import com.ironmind.app.data.local.relation.SessionWithSets +import com.ironmind.app.domain.model.MuscleGroup +import kotlinx.coroutines.flow.Flow + +/** + * Single data-access object for the whole schema. Read operations return [Flow] (Room emits + * a new value whenever the underlying tables change); writes are `suspend`. Queries that + * hydrate `@Relation` POJOs are marked `@Transaction` so the multi-query read is consistent. + */ +@Dao +interface WorkoutDao { + + // --------------------------------------------------------------------------------- + // Exercises + // --------------------------------------------------------------------------------- + @Query("SELECT * FROM exercises ORDER BY name ASC") + fun observeExercises(): Flow> + + @Query("SELECT * FROM exercises WHERE muscleGroup = :muscleGroup ORDER BY name ASC") + fun observeExercisesByMuscleGroup(muscleGroup: MuscleGroup): Flow> + + @Query("SELECT * FROM exercises WHERE name LIKE '%' || :query || '%' ORDER BY name ASC") + fun searchExercises(query: String): Flow> + + @Query("SELECT * FROM exercises WHERE id = :id") + suspend fun getExerciseById(id: Long): ExerciseEntity? + + @Query("SELECT COUNT(*) FROM exercises") + suspend fun countExercises(): Int + + @Upsert + suspend fun upsertExercise(exercise: ExerciseEntity): Long + + @Upsert + suspend fun upsertExercises(exercises: List) + + @Delete + suspend fun deleteExercise(exercise: ExerciseEntity) + + // --------------------------------------------------------------------------------- + // Routines + // --------------------------------------------------------------------------------- + @Query("SELECT * FROM routines ORDER BY position ASC, createdAt ASC") + fun observeRoutines(): Flow> + + @Query("SELECT * FROM routines WHERE id = :id") + suspend fun getRoutineById(id: Long): RoutineEntity? + + @Upsert + suspend fun upsertRoutine(routine: RoutineEntity): Long + + @Delete + suspend fun deleteRoutine(routine: RoutineEntity) + + // ---- Routine ↔ Exercise junction ---- + @Upsert + suspend fun upsertRoutineExerciseCrossRef(crossRef: RoutineExerciseCrossRef) + + @Delete + suspend fun deleteRoutineExerciseCrossRef(crossRef: RoutineExerciseCrossRef) + + @Query("DELETE FROM routine_exercise_cross_ref WHERE routineId = :routineId AND exerciseId = :exerciseId") + suspend fun removeExerciseFromRoutine(routineId: Long, exerciseId: Long) + + // ---- Routine + exercises relation ---- + @Transaction + @Query("SELECT * FROM routines ORDER BY position ASC, createdAt ASC") + fun observeRoutinesWithExercises(): Flow> + + @Transaction + @Query("SELECT * FROM routines WHERE id = :routineId") + fun observeRoutineWithExercises(routineId: Long): Flow + + // --------------------------------------------------------------------------------- + // Workout sessions + // --------------------------------------------------------------------------------- + @Query("SELECT * FROM workout_sessions ORDER BY startedAt DESC") + fun observeSessions(): Flow> + + @Query("SELECT * FROM workout_sessions WHERE id = :id") + suspend fun getSessionById(id: Long): WorkoutSessionEntity? + + @Insert + suspend fun insertSession(session: WorkoutSessionEntity): Long + + @Update + suspend fun updateSession(session: WorkoutSessionEntity) + + @Delete + suspend fun deleteSession(session: WorkoutSessionEntity) + + // ---- Session + sets relation ---- + @Transaction + @Query("SELECT * FROM workout_sessions ORDER BY startedAt DESC") + fun observeSessionsWithSets(): Flow> + + @Transaction + @Query("SELECT * FROM workout_sessions WHERE id = :sessionId") + fun observeSessionWithSets(sessionId: Long): Flow + + // --------------------------------------------------------------------------------- + // Set logs + // --------------------------------------------------------------------------------- + @Query("SELECT * FROM set_logs WHERE sessionId = :sessionId ORDER BY exerciseId ASC, setNumber ASC") + fun observeSetLogsForSession(sessionId: Long): Flow> + + @Query("SELECT * FROM set_logs WHERE exerciseId = :exerciseId ORDER BY performedAt DESC") + fun observeSetLogsForExercise(exerciseId: Long): Flow> + + @Query("SELECT * FROM set_logs WHERE exerciseId = :exerciseId ORDER BY performedAt DESC LIMIT 1") + suspend fun getLastSetLogForExercise(exerciseId: Long): SetLogEntity? + + @Upsert + suspend fun upsertSetLog(setLog: SetLogEntity): Long + + @Delete + suspend fun deleteSetLog(setLog: SetLogEntity) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/ExerciseEntity.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/ExerciseEntity.kt new file mode 100644 index 0000000..7711629 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/ExerciseEntity.kt @@ -0,0 +1,21 @@ +package com.ironmind.app.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.MuscleGroup + +/** Room representation of an exercise in the catalog. */ +@Entity( + tableName = "exercises", + indices = [Index(value = ["name"], unique = true)], +) +data class ExerciseEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val muscleGroup: MuscleGroup, + val equipment: Equipment, + val description: String? = null, + val isCustom: Boolean = true, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineEntity.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineEntity.kt new file mode 100644 index 0000000..ef359e0 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineEntity.kt @@ -0,0 +1,16 @@ +package com.ironmind.app.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ironmind.app.domain.model.RoutineSplit + +/** Room representation of a routine / training day (e.g. a "Push" day). */ +@Entity(tableName = "routines") +data class RoutineEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val split: RoutineSplit, + val description: String? = null, + val position: Int = 0, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineExerciseCrossRef.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineExerciseCrossRef.kt new file mode 100644 index 0000000..cd1f784 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/RoutineExerciseCrossRef.kt @@ -0,0 +1,38 @@ +package com.ironmind.app.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Junction table wiring the many-to-many relationship between routines and exercises. + * It also stores the per-routine prescription (target sets / reps / rest) and the + * ordering of exercises within a routine. + */ +@Entity( + tableName = "routine_exercise_cross_ref", + primaryKeys = ["routineId", "exerciseId"], + foreignKeys = [ + ForeignKey( + entity = RoutineEntity::class, + parentColumns = ["id"], + childColumns = ["routineId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ExerciseEntity::class, + parentColumns = ["id"], + childColumns = ["exerciseId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("routineId"), Index("exerciseId")], +) +data class RoutineExerciseCrossRef( + val routineId: Long, + val exerciseId: Long, + val position: Int = 0, + val targetSets: Int = 3, + val targetReps: Int = 10, + val targetRestSeconds: Int = 90, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/SetLogEntity.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/SetLogEntity.kt new file mode 100644 index 0000000..0646e92 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/SetLogEntity.kt @@ -0,0 +1,44 @@ +package com.ironmind.app.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room representation of a single logged set. Cascades on deletion of its parent session + * or exercise. [notes] holds free-form context (supplements, energy, form cues) that the + * on-device AI can later summarize. + */ +@Entity( + tableName = "set_logs", + foreignKeys = [ + ForeignKey( + entity = WorkoutSessionEntity::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ExerciseEntity::class, + parentColumns = ["id"], + childColumns = ["exerciseId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sessionId"), Index("exerciseId")], +) +data class SetLogEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val sessionId: Long, + val exerciseId: Long, + val setNumber: Int, + val weightKg: Double, + val reps: Int, + val rpe: Float? = null, + val isWarmup: Boolean = false, + val isCompleted: Boolean = true, + val restSeconds: Int? = null, + val notes: String? = null, + val performedAt: Long = System.currentTimeMillis(), +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/WorkoutSessionEntity.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/WorkoutSessionEntity.kt new file mode 100644 index 0000000..6f1793c --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/entity/WorkoutSessionEntity.kt @@ -0,0 +1,31 @@ +package com.ironmind.app.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room representation of a performed workout session. The link to [RoutineEntity] is + * optional (a session can be freestyle) and is nulled out if the routine is deleted. + */ +@Entity( + tableName = "workout_sessions", + foreignKeys = [ + ForeignKey( + entity = RoutineEntity::class, + parentColumns = ["id"], + childColumns = ["routineId"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [Index("routineId")], +) +data class WorkoutSessionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val routineId: Long? = null, + val title: String? = null, + val startedAt: Long, + val endedAt: Long? = null, + val notes: String? = null, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/relation/RoutineWithExercises.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/relation/RoutineWithExercises.kt new file mode 100644 index 0000000..2c8a20f --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/relation/RoutineWithExercises.kt @@ -0,0 +1,26 @@ +package com.ironmind.app.data.local.relation + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import com.ironmind.app.data.local.entity.ExerciseEntity +import com.ironmind.app.data.local.entity.RoutineExerciseCrossRef +import com.ironmind.app.data.local.entity.RoutineEntity + +/** + * A routine together with all of its exercises, resolved through the + * [RoutineExerciseCrossRef] junction table. + */ +data class RoutineWithExercises( + @Embedded val routine: RoutineEntity, + @Relation( + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = RoutineExerciseCrossRef::class, + parentColumn = "routineId", + entityColumn = "exerciseId", + ), + ) + val exercises: List, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/relation/SessionWithSets.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/relation/SessionWithSets.kt new file mode 100644 index 0000000..5ef379c --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/relation/SessionWithSets.kt @@ -0,0 +1,18 @@ +package com.ironmind.app.data.local.relation + +import androidx.room.Embedded +import androidx.room.Relation +import com.ironmind.app.data.local.entity.SetLogEntity +import com.ironmind.app.data.local.entity.WorkoutSessionEntity + +/** + * A workout session together with every set logged during it. + */ +data class SessionWithSets( + @Embedded val session: WorkoutSessionEntity, + @Relation( + parentColumn = "id", + entityColumn = "sessionId", + ) + val sets: List, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/seed/DefaultExercises.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/seed/DefaultExercises.kt new file mode 100644 index 0000000..3368691 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/seed/DefaultExercises.kt @@ -0,0 +1,60 @@ +package com.ironmind.app.data.local.seed + +import com.ironmind.app.data.local.dao.WorkoutDao +import com.ironmind.app.data.local.entity.ExerciseEntity +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.MuscleGroup + +/** + * Ships a starter catalog of common exercises so the app is useful on first launch while + * remaining 100% offline. Seeded rows are marked `isCustom = false`. + */ +object DefaultExercises { + + /** Inserts the starter catalog if the exercises table is empty. */ + suspend fun seed(dao: WorkoutDao) { + if (dao.countExercises() > 0) return + dao.upsertExercises(catalog) + } + + private fun ex( + name: String, + muscleGroup: MuscleGroup, + equipment: Equipment, + ) = ExerciseEntity( + name = name, + muscleGroup = muscleGroup, + equipment = equipment, + isCustom = false, + ) + + private val catalog: List = listOf( + // Push + ex("Barbell Bench Press", MuscleGroup.CHEST, Equipment.BARBELL), + ex("Incline Dumbbell Press", MuscleGroup.CHEST, Equipment.DUMBBELL), + ex("Cable Fly", MuscleGroup.CHEST, Equipment.CABLE), + ex("Overhead Press", MuscleGroup.SHOULDERS, Equipment.BARBELL), + ex("Lateral Raise", MuscleGroup.SHOULDERS, Equipment.DUMBBELL), + ex("Triceps Pushdown", MuscleGroup.TRICEPS, Equipment.CABLE), + ex("Overhead Triceps Extension", MuscleGroup.TRICEPS, Equipment.DUMBBELL), + // Pull + ex("Deadlift", MuscleGroup.BACK, Equipment.BARBELL), + ex("Pull-Up", MuscleGroup.BACK, Equipment.BODYWEIGHT), + ex("Bent-Over Barbell Row", MuscleGroup.BACK, Equipment.BARBELL), + ex("Lat Pulldown", MuscleGroup.BACK, Equipment.CABLE), + ex("Face Pull", MuscleGroup.SHOULDERS, Equipment.CABLE), + ex("Barbell Curl", MuscleGroup.BICEPS, Equipment.BARBELL), + ex("Hammer Curl", MuscleGroup.BICEPS, Equipment.DUMBBELL), + // Legs + ex("Back Squat", MuscleGroup.QUADS, Equipment.BARBELL), + ex("Leg Press", MuscleGroup.QUADS, Equipment.MACHINE), + ex("Romanian Deadlift", MuscleGroup.HAMSTRINGS, Equipment.BARBELL), + ex("Leg Curl", MuscleGroup.HAMSTRINGS, Equipment.MACHINE), + ex("Hip Thrust", MuscleGroup.GLUTES, Equipment.BARBELL), + ex("Standing Calf Raise", MuscleGroup.CALVES, Equipment.MACHINE), + // Core + ex("Hanging Leg Raise", MuscleGroup.ABS, Equipment.BODYWEIGHT), + ex("Cable Crunch", MuscleGroup.ABS, Equipment.CABLE), + ex("Plank", MuscleGroup.ABS, Equipment.BODYWEIGHT), + ) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/mapper/EntityMappers.kt b/IronMind/app/src/main/java/com/ironmind/app/data/mapper/EntityMappers.kt new file mode 100644 index 0000000..dbea9b5 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/mapper/EntityMappers.kt @@ -0,0 +1,117 @@ +package com.ironmind.app.data.mapper + +import com.ironmind.app.data.local.entity.ExerciseEntity +import com.ironmind.app.data.local.entity.RoutineEntity +import com.ironmind.app.data.local.entity.SetLogEntity +import com.ironmind.app.data.local.entity.WorkoutSessionEntity +import com.ironmind.app.data.local.relation.RoutineWithExercises +import com.ironmind.app.data.local.relation.SessionWithSets +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.Routine +import com.ironmind.app.domain.model.RoutinePlan +import com.ironmind.app.domain.model.SessionDetail +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.domain.model.WorkoutSession + +/** + * Pure mapping functions between Room entities/relations and domain models. Keeping this + * translation in the data layer lets the domain and presentation layers stay ignorant of Room. + */ + +// ---- Exercise ----------------------------------------------------------------------- +fun ExerciseEntity.toDomain(): Exercise = Exercise( + id = id, + name = name, + muscleGroup = muscleGroup, + equipment = equipment, + description = description, + isCustom = isCustom, +) + +fun Exercise.toEntity(): ExerciseEntity = ExerciseEntity( + id = id, + name = name, + muscleGroup = muscleGroup, + equipment = equipment, + description = description, + isCustom = isCustom, +) + +// ---- Routine ------------------------------------------------------------------------ +fun RoutineEntity.toDomain(): Routine = Routine( + id = id, + name = name, + split = split, + description = description, + position = position, + createdAt = createdAt, +) + +fun Routine.toEntity(): RoutineEntity = RoutineEntity( + id = id, + name = name, + split = split, + description = description, + position = position, + createdAt = createdAt, +) + +fun RoutineWithExercises.toDomain(): RoutinePlan = RoutinePlan( + routine = routine.toDomain(), + exercises = exercises.map { it.toDomain() }, +) + +// ---- Workout session ---------------------------------------------------------------- +fun WorkoutSessionEntity.toDomain(): WorkoutSession = WorkoutSession( + id = id, + routineId = routineId, + title = title, + startedAt = startedAt, + endedAt = endedAt, + notes = notes, +) + +fun WorkoutSession.toEntity(): WorkoutSessionEntity = WorkoutSessionEntity( + id = id, + routineId = routineId, + title = title, + startedAt = startedAt, + endedAt = endedAt, + notes = notes, +) + +// ---- Set log ------------------------------------------------------------------------ +fun SetLogEntity.toDomain(): SetLog = SetLog( + id = id, + sessionId = sessionId, + exerciseId = exerciseId, + setNumber = setNumber, + weightKg = weightKg, + reps = reps, + rpe = rpe, + isWarmup = isWarmup, + isCompleted = isCompleted, + restSeconds = restSeconds, + notes = notes, + performedAt = performedAt, +) + +fun SetLog.toEntity(): SetLogEntity = SetLogEntity( + id = id, + sessionId = sessionId, + exerciseId = exerciseId, + setNumber = setNumber, + weightKg = weightKg, + reps = reps, + rpe = rpe, + isWarmup = isWarmup, + isCompleted = isCompleted, + restSeconds = restSeconds, + notes = notes, + performedAt = performedAt, +) + +fun SessionWithSets.toDomain(): SessionDetail = SessionDetail( + session = session.toDomain(), + sets = sets.map { it.toDomain() }, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt new file mode 100644 index 0000000..88511e9 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt @@ -0,0 +1,132 @@ +package com.ironmind.app.data.repository + +import com.ironmind.app.core.util.DispatcherProvider +import com.ironmind.app.data.local.dao.WorkoutDao +import com.ironmind.app.data.local.entity.RoutineExerciseCrossRef +import com.ironmind.app.data.mapper.toDomain +import com.ironmind.app.data.mapper.toEntity +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.Routine +import com.ironmind.app.domain.model.RoutinePlan +import com.ironmind.app.domain.model.SessionDetail +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.domain.model.WorkoutSession +import com.ironmind.app.domain.repository.WorkoutRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Room-backed implementation of [WorkoutRepository]. Flow reads are mapped from entities to + * domain models lazily; suspending writes are dispatched onto the IO dispatcher. + */ +class WorkoutRepositoryImpl @Inject constructor( + private val dao: WorkoutDao, + private val dispatchers: DispatcherProvider, +) : WorkoutRepository { + + // ---- Exercises ------------------------------------------------------------------ + override fun observeExercises(): Flow> = + dao.observeExercises().map { list -> list.map { it.toDomain() } } + + override fun observeExercisesByMuscleGroup(muscleGroup: MuscleGroup): Flow> = + dao.observeExercisesByMuscleGroup(muscleGroup).map { list -> list.map { it.toDomain() } } + + override fun searchExercises(query: String): Flow> = + dao.searchExercises(query).map { list -> list.map { it.toDomain() } } + + override suspend fun getExercise(id: Long): Exercise? = withContext(dispatchers.io) { + dao.getExerciseById(id)?.toDomain() + } + + override suspend fun upsertExercise(exercise: Exercise): Long = withContext(dispatchers.io) { + dao.upsertExercise(exercise.toEntity()) + } + + override suspend fun deleteExercise(exercise: Exercise) = withContext(dispatchers.io) { + dao.deleteExercise(exercise.toEntity()) + } + + // ---- Routines ------------------------------------------------------------------- + override fun observeRoutines(): Flow> = + dao.observeRoutines().map { list -> list.map { it.toDomain() } } + + override fun observeRoutinePlans(): Flow> = + dao.observeRoutinesWithExercises().map { list -> list.map { it.toDomain() } } + + override fun observeRoutinePlan(routineId: Long): Flow = + dao.observeRoutineWithExercises(routineId).map { it?.toDomain() } + + override suspend fun upsertRoutine(routine: Routine): Long = withContext(dispatchers.io) { + dao.upsertRoutine(routine.toEntity()) + } + + override suspend fun deleteRoutine(routine: Routine) = withContext(dispatchers.io) { + dao.deleteRoutine(routine.toEntity()) + } + + override suspend fun addExerciseToRoutine( + routineId: Long, + exerciseId: Long, + position: Int, + targetSets: Int, + targetReps: Int, + targetRestSeconds: Int, + ) = withContext(dispatchers.io) { + dao.upsertRoutineExerciseCrossRef( + RoutineExerciseCrossRef( + routineId = routineId, + exerciseId = exerciseId, + position = position, + targetSets = targetSets, + targetReps = targetReps, + targetRestSeconds = targetRestSeconds, + ), + ) + } + + override suspend fun removeExerciseFromRoutine(routineId: Long, exerciseId: Long) = + withContext(dispatchers.io) { + dao.removeExerciseFromRoutine(routineId, exerciseId) + } + + // ---- Sessions & set logs -------------------------------------------------------- + override fun observeSessions(): Flow> = + dao.observeSessions().map { list -> list.map { it.toDomain() } } + + override fun observeSessionDetails(): Flow> = + dao.observeSessionsWithSets().map { list -> list.map { it.toDomain() } } + + override fun observeSessionDetail(sessionId: Long): Flow = + dao.observeSessionWithSets(sessionId).map { it?.toDomain() } + + override suspend fun startSession(session: WorkoutSession): Long = withContext(dispatchers.io) { + dao.insertSession(session.toEntity()) + } + + override suspend fun updateSession(session: WorkoutSession) = withContext(dispatchers.io) { + dao.updateSession(session.toEntity()) + } + + override suspend fun deleteSession(session: WorkoutSession) = withContext(dispatchers.io) { + dao.deleteSession(session.toEntity()) + } + + override fun observeSetLogsForExercise(exerciseId: Long): Flow> = + dao.observeSetLogsForExercise(exerciseId).map { list -> list.map { it.toDomain() } } + + override suspend fun getLastSetLogForExercise(exerciseId: Long): SetLog? = + withContext(dispatchers.io) { + dao.getLastSetLogForExercise(exerciseId)?.toDomain() + } + + override suspend fun upsertSetLog(setLog: SetLog): Long = withContext(dispatchers.io) { + dao.upsertSetLog(setLog.toEntity()) + } + + override suspend fun deleteSetLog(setLog: SetLog) = withContext(dispatchers.io) { + dao.deleteSetLog(setLog.toEntity()) + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/di/CoroutinesModule.kt b/IronMind/app/src/main/java/com/ironmind/app/di/CoroutinesModule.kt new file mode 100644 index 0000000..a907ead --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/di/CoroutinesModule.kt @@ -0,0 +1,27 @@ +package com.ironmind.app.di + +import com.ironmind.app.core.util.DispatcherProvider +import com.ironmind.app.core.util.StandardDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +/** Provides coroutine infrastructure: the dispatcher abstraction and the app-wide scope. */ +@Module +@InstallIn(SingletonComponent::class) +object CoroutinesModule { + + @Provides + @Singleton + fun provideDispatcherProvider(): DispatcherProvider = StandardDispatcherProvider() + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(dispatchers: DispatcherProvider): CoroutineScope = + CoroutineScope(SupervisorJob() + dispatchers.default) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/di/DatabaseModule.kt b/IronMind/app/src/main/java/com/ironmind/app/di/DatabaseModule.kt new file mode 100644 index 0000000..1b192da --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/di/DatabaseModule.kt @@ -0,0 +1,51 @@ +package com.ironmind.app.di + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.ironmind.app.core.util.Constants +import com.ironmind.app.data.local.IronMindDatabase +import com.ironmind.app.data.local.dao.WorkoutDao +import com.ironmind.app.data.local.seed.DefaultExercises +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Provider +import javax.inject.Singleton + +/** Wires up the Room database and its DAO. */ +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase( + @ApplicationContext context: Context, + // Lazy provider avoids a cycle: the seed callback needs the DAO, which needs the DB. + daoProvider: Provider, + @ApplicationScope scope: CoroutineScope, + ): IronMindDatabase = + Room.databaseBuilder( + context, + IronMindDatabase::class.java, + Constants.DATABASE_NAME, + ) + // No migrations exist at v1; recreate on schema change during early development. + .fallbackToDestructiveMigration() + .addCallback(object : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + scope.launch { DefaultExercises.seed(daoProvider.get()) } + } + }) + .build() + + @Provides + fun provideWorkoutDao(database: IronMindDatabase): WorkoutDao = database.workoutDao() +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/di/Qualifiers.kt b/IronMind/app/src/main/java/com/ironmind/app/di/Qualifiers.kt new file mode 100644 index 0000000..7b06e88 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/di/Qualifiers.kt @@ -0,0 +1,8 @@ +package com.ironmind.app.di + +import javax.inject.Qualifier + +/** Marks the application-wide [kotlinx.coroutines.CoroutineScope] (lives for the process). */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/IronMind/app/src/main/java/com/ironmind/app/di/RepositoryModule.kt b/IronMind/app/src/main/java/com/ironmind/app/di/RepositoryModule.kt new file mode 100644 index 0000000..a938967 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/di/RepositoryModule.kt @@ -0,0 +1,19 @@ +package com.ironmind.app.di + +import com.ironmind.app.data.repository.WorkoutRepositoryImpl +import com.ironmind.app.domain.repository.WorkoutRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** Binds domain repository interfaces to their data-layer implementations. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindWorkoutRepository(impl: WorkoutRepositoryImpl): WorkoutRepository +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/Equipment.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/Equipment.kt new file mode 100644 index 0000000..6f6599a --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/Equipment.kt @@ -0,0 +1,13 @@ +package com.ironmind.app.domain.model + +/** The equipment an exercise is performed with. */ +enum class Equipment { + BARBELL, + DUMBBELL, + MACHINE, + CABLE, + KETTLEBELL, + BODYWEIGHT, + BAND, + OTHER, +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/Exercise.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/Exercise.kt new file mode 100644 index 0000000..c0fcbc7 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/Exercise.kt @@ -0,0 +1,15 @@ +package com.ironmind.app.domain.model + +/** + * A single exercise in the catalog (e.g. "Barbell Bench Press"). + * Pure domain model — free of any Room/persistence annotations. + */ +data class Exercise( + val id: Long = 0, + val name: String, + val muscleGroup: MuscleGroup, + val equipment: Equipment, + val description: String? = null, + /** `true` for user-created exercises, `false` for the seeded catalog. */ + val isCustom: Boolean = true, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/MuscleGroup.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/MuscleGroup.kt new file mode 100644 index 0000000..22c6669 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/MuscleGroup.kt @@ -0,0 +1,18 @@ +package com.ironmind.app.domain.model + +/** The primary muscle group an exercise targets. */ +enum class MuscleGroup { + CHEST, + BACK, + SHOULDERS, + BICEPS, + TRICEPS, + FOREARMS, + QUADS, + HAMSTRINGS, + GLUTES, + CALVES, + ABS, + FULL_BODY, + OTHER, +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/Routine.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/Routine.kt new file mode 100644 index 0000000..ecb72b1 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/Routine.kt @@ -0,0 +1,14 @@ +package com.ironmind.app.domain.model + +/** + * A planned routine (a training day) grouping a set of exercises under a [split]. + */ +data class Routine( + val id: Long = 0, + val name: String, + val split: RoutineSplit, + val description: String? = null, + /** Ordering position within the user's list of routines. */ + val position: Int = 0, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutinePlan.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutinePlan.kt new file mode 100644 index 0000000..a88548b --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutinePlan.kt @@ -0,0 +1,10 @@ +package com.ironmind.app.domain.model + +/** + * A [Routine] together with the exercises it prescribes. This is the domain + * counterpart of the `RoutineWithExercises` Room relation. + */ +data class RoutinePlan( + val routine: Routine, + val exercises: List, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutineSplit.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutineSplit.kt new file mode 100644 index 0000000..940ea99 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/RoutineSplit.kt @@ -0,0 +1,21 @@ +package com.ironmind.app.domain.model + +/** + * The training split a routine belongs to. Supports classic divisions such as + * Push / Pull / Legs, as well as upper/lower and full-body layouts. + */ +enum class RoutineSplit { + PUSH, + PULL, + LEGS, + UPPER, + LOWER, + FULL_BODY, + CHEST_TRICEPS, + BACK_BICEPS, + SHOULDERS, + ARMS, + CORE, + CARDIO, + CUSTOM, +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/SessionDetail.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/SessionDetail.kt new file mode 100644 index 0000000..62c9a51 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/SessionDetail.kt @@ -0,0 +1,18 @@ +package com.ironmind.app.domain.model + +/** + * A [WorkoutSession] together with every set logged during it. Domain counterpart of + * the `SessionWithSets` Room relation. + */ +data class SessionDetail( + val session: WorkoutSession, + val sets: List, +) { + /** Total training volume across all completed working sets. */ + val totalVolume: Double + get() = sets.filter { it.isCompleted && !it.isWarmup }.sumOf { it.volume } + + /** Number of completed working sets (warm-ups excluded). */ + val workingSetCount: Int + get() = sets.count { it.isCompleted && !it.isWarmup } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/SetLog.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/SetLog.kt new file mode 100644 index 0000000..b4c813d --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/SetLog.kt @@ -0,0 +1,28 @@ +package com.ironmind.app.domain.model + +/** + * A single logged set inside a [WorkoutSession]. + * + * [notes] is a free-form field intended for context the on-device AI can reason over — + * e.g. supplement intake ("Optimum Nutrition Gold Standard 100% Whey"), energy levels, + * form cues, or how a lift felt. + */ +data class SetLog( + val id: Long = 0, + val sessionId: Long, + val exerciseId: Long, + val setNumber: Int, + val weightKg: Double, + val reps: Int, + /** Rate of Perceived Exertion (1–10), optional. */ + val rpe: Float? = null, + val isWarmup: Boolean = false, + val isCompleted: Boolean = true, + val restSeconds: Int? = null, + val notes: String? = null, + val performedAt: Long = System.currentTimeMillis(), +) { + /** Training volume for this set (weight × reps). */ + val volume: Double + get() = weightKg * reps +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/WorkoutSession.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/WorkoutSession.kt new file mode 100644 index 0000000..66371ff --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/WorkoutSession.kt @@ -0,0 +1,18 @@ +package com.ironmind.app.domain.model + +/** + * A performed training session. Optionally linked to the [Routine] it was based on. + * Session-level [notes] can capture pre-workout state (sleep, energy, supplements). + */ +data class WorkoutSession( + val id: Long = 0, + val routineId: Long? = null, + val title: String? = null, + val startedAt: Long, + val endedAt: Long? = null, + val notes: String? = null, +) { + /** Elapsed duration in milliseconds, or `null` while the session is still open. */ + val durationMillis: Long? + get() = endedAt?.let { it - startedAt } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt new file mode 100644 index 0000000..f0ac527 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt @@ -0,0 +1,59 @@ +package com.ironmind.app.domain.repository + +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.Routine +import com.ironmind.app.domain.model.RoutinePlan +import com.ironmind.app.domain.model.SessionDetail +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.domain.model.WorkoutSession +import kotlinx.coroutines.flow.Flow + +/** + * The domain-facing contract for all persistence. The presentation layer (via use cases, + * added in Stage 2) depends only on this interface — never on Room. Reads expose [Flow] + * so the UI updates reactively; writes are `suspend` so they run off the main thread. + */ +interface WorkoutRepository { + + // ---- Exercises ------------------------------------------------------------------ + fun observeExercises(): Flow> + fun observeExercisesByMuscleGroup(muscleGroup: MuscleGroup): Flow> + fun searchExercises(query: String): Flow> + suspend fun getExercise(id: Long): Exercise? + suspend fun upsertExercise(exercise: Exercise): Long + suspend fun deleteExercise(exercise: Exercise) + + // ---- Routines ------------------------------------------------------------------- + fun observeRoutines(): Flow> + fun observeRoutinePlans(): Flow> + fun observeRoutinePlan(routineId: Long): Flow + suspend fun upsertRoutine(routine: Routine): Long + suspend fun deleteRoutine(routine: Routine) + + /** Adds [exerciseId] to [routineId] with an optional prescription. */ + suspend fun addExerciseToRoutine( + routineId: Long, + exerciseId: Long, + position: Int = 0, + targetSets: Int = 3, + targetReps: Int = 10, + targetRestSeconds: Int = 90, + ) + + suspend fun removeExerciseFromRoutine(routineId: Long, exerciseId: Long) + + // ---- Sessions & set logs -------------------------------------------------------- + fun observeSessions(): Flow> + fun observeSessionDetails(): Flow> + fun observeSessionDetail(sessionId: Long): Flow + suspend fun startSession(session: WorkoutSession): Long + suspend fun updateSession(session: WorkoutSession) + suspend fun deleteSession(session: WorkoutSession) + + fun observeSetLogsForExercise(exerciseId: Long): Flow> + /** The most recent logged set for an exercise — the "last time" reference for progression. */ + suspend fun getLastSetLogForExercise(exerciseId: Long): SetLog? + suspend fun upsertSetLog(setLog: SetLog): Long + suspend fun deleteSetLog(setLog: SetLog) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Color.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Color.kt new file mode 100644 index 0000000..a09d556 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Color.kt @@ -0,0 +1,16 @@ +package com.ironmind.app.ui.theme + +import androidx.compose.ui.graphics.Color + +// Core surface +val Black = Color(0xFF000000) +val White = Color(0xFFFFFFFF) + +// Dynamic accents +val Gold = Color(0xFFFFD700) +val Cyan = Color(0xFF00FFFF) + +// Glassmorphism helpers +val GlassFill = Color(0x14FFFFFF) // ~8% white — frosted panel fill +val GlassBorder = Color(0x33FFFFFF) // ~20% white — subtle glass edge +val TextMuted = Color(0xFFB3B3B3) diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt new file mode 100644 index 0000000..8d0261c --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt @@ -0,0 +1,32 @@ +package com.ironmind.app.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable + +/** + * IronMind is a dark-only, pure-black experience. Gold is the primary accent, cyan the + * secondary — see the UI/UX brief (glassmorphism over #000000). + */ +private val IronMindColorScheme = darkColorScheme( + primary = Gold, + onPrimary = Black, + secondary = Cyan, + onSecondary = Black, + background = Black, + onBackground = White, + surface = Black, + onSurface = White, + surfaceVariant = GlassFill, + onSurfaceVariant = TextMuted, + outline = GlassBorder, +) + +@Composable +fun IronMindTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = IronMindColorScheme, + typography = Typography, + content = content, + ) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Type.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Type.kt new file mode 100644 index 0000000..3876c8c --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Type.kt @@ -0,0 +1,41 @@ +package com.ironmind.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Clean, modern type scale. Uses the platform default sans family for now; a custom + * display font can be dropped in during the Stage 3 UI pass. + */ +val Typography = Typography( + headlineLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/IronMind/app/src/main/res/drawable/ic_launcher_background.xml b/IronMind/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..dd7e7f1 --- /dev/null +++ b/IronMind/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/IronMind/app/src/main/res/drawable/ic_launcher_foreground.xml b/IronMind/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..37d181f --- /dev/null +++ b/IronMind/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/IronMind/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/IronMind/app/src/main/res/values/colors.xml b/IronMind/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..16ef255 --- /dev/null +++ b/IronMind/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + + #FF000000 + #FFFFFFFF + + + #FFFFD700 + #FF00FFFF + diff --git a/IronMind/app/src/main/res/values/strings.xml b/IronMind/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3e5d8de --- /dev/null +++ b/IronMind/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + IronMind + diff --git a/IronMind/app/src/main/res/values/themes.xml b/IronMind/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..77c2565 --- /dev/null +++ b/IronMind/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/IronMind/app/src/main/res/xml/backup_rules.xml b/IronMind/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fb8aefa --- /dev/null +++ b/IronMind/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/IronMind/app/src/main/res/xml/data_extraction_rules.xml b/IronMind/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..6913932 --- /dev/null +++ b/IronMind/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/IronMind/app/src/test/java/com/ironmind/app/EntityMappersTest.kt b/IronMind/app/src/test/java/com/ironmind/app/EntityMappersTest.kt new file mode 100644 index 0000000..26f29a4 --- /dev/null +++ b/IronMind/app/src/test/java/com/ironmind/app/EntityMappersTest.kt @@ -0,0 +1,44 @@ +package com.ironmind.app + +import com.ironmind.app.data.mapper.toDomain +import com.ironmind.app.data.mapper.toEntity +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.SetLog +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Pure JVM unit tests for the entity ↔ domain mappers. */ +class EntityMappersTest { + + @Test + fun exercise_roundTripsThroughEntity() { + val original = Exercise( + id = 7, + name = "Front Squat", + muscleGroup = MuscleGroup.QUADS, + equipment = Equipment.BARBELL, + description = "Elbows high", + isCustom = false, + ) + assertEquals(original, original.toEntity().toDomain()) + } + + @Test + fun setLog_roundTripsAndComputesVolume() { + val original = SetLog( + id = 1, + sessionId = 2, + exerciseId = 3, + setNumber = 1, + weightKg = 80.0, + reps = 5, + rpe = 8.5f, + notes = "Energy 9/10", + ) + val roundTripped = original.toEntity().toDomain() + assertEquals(original, roundTripped) + assertEquals(400.0, roundTripped.volume, 0.0001) + } +} diff --git a/IronMind/build.gradle.kts b/IronMind/build.gradle.kts new file mode 100644 index 0000000..13183a1 --- /dev/null +++ b/IronMind/build.gradle.kts @@ -0,0 +1,9 @@ +// Top-level build file — configuration shared across modules lives here. +// Plugins are declared (but not applied) so subprojects can apply them by alias. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false +} diff --git a/IronMind/gradle.properties b/IronMind/gradle.properties new file mode 100644 index 0000000..2f0c1d0 --- /dev/null +++ b/IronMind/gradle.properties @@ -0,0 +1,15 @@ +# JVM args for the Gradle daemon +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# Enable parallel and cached builds +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX +android.useAndroidX=true + +# Non-transitive R classes shrink the R class and speed up builds +android.nonTransitiveRClass=true + +# Kotlin +kotlin.code.style=official diff --git a/IronMind/gradle/libs.versions.toml b/IronMind/gradle/libs.versions.toml new file mode 100644 index 0000000..9ce361a --- /dev/null +++ b/IronMind/gradle/libs.versions.toml @@ -0,0 +1,87 @@ +[versions] +# Build tooling +agp = "8.7.3" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" + +# AndroidX core / lifecycle +coreKtx = "1.15.0" +lifecycle = "2.8.7" +activityCompose = "1.9.3" + +# Compose +composeBom = "2024.12.01" + +# Persistence +room = "2.6.1" + +# Dependency injection +hilt = "2.54" +hiltNavigationCompose = "1.2.0" + +# Concurrency +coroutines = "1.9.0" + +# On-device LLM (Google AI Edge / MediaPipe LLM Inference API) — wired in Stage 3 +mediapipeGenai = "0.10.24" + +# Testing +junit = "4.13.2" +androidxJunit = "1.2.1" +espresso = "3.6.1" + +[libraries] +# Core / lifecycle +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +# Compose (versions come from the BOM) +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +# Room +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +# Coroutines +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +# On-device LLM — referenced in Stage 3 (kept here so the catalog is ready) +mediapipe-tasks-genai = { group = "com.google.mediapipe", name = "tasks-genai", version.ref = "mediapipeGenai" } + +# Testing +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } + +[bundles] +compose = [ + "androidx-ui", + "androidx-ui-graphics", + "androidx-ui-tooling-preview", + "androidx-material3", + "androidx-material-icons-extended", +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/IronMind/gradle/wrapper/gradle-wrapper.jar b/IronMind/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/IronMind/gradlew.bat b/IronMind/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/IronMind/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/IronMind/settings.gradle.kts b/IronMind/settings.gradle.kts new file mode 100644 index 0000000..9eb6ca9 --- /dev/null +++ b/IronMind/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "IronMind" +include(":app") From 65b0e18ac5fd801121c273ddd531c7f27901f2da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:41:17 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(ironmind):=20Stage=202=20=E2=80=94=20o?= =?UTF-8?q?n-device=20AI=20(MediaPipe=20LLM=20Inference)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the on-device intelligence layer. No network is used; inference runs entirely on-device via the Google AI Edge / MediaPipe LLM Inference API. - LlmInferenceService (domain port) + LlmInferenceManager (data impl): lazily loads the MediaPipe engine on Dispatchers.IO, opens a session per request, and streams the response as Flow via callbackFlow (typewriter effect). AiConstants holds model path + sampling params. - ProgressionPromptBuilder (domain): pure, testable builder that frames the model as a strength coach and lays out the exercise's recent history. - GetProgressionSuggestionUseCase (domain): reads recent set logs from Room, builds the prompt, streams the answer, and emits SuggestionState. - SuggestionState sealed class: Loading / Success(text, isComplete) / Error; Success is re-emitted with accumulating text for the typewriter effect. - WorkoutDao/WorkoutRepository: add getRecentSetLogs(exerciseId, limit) for the AI history window. - AiModule (Hilt) binds the port to the MediaPipe manager. - Enable the mediapipe tasks-genai dependency. - Tests: ProgressionPromptBuilder + GetProgressionSuggestionUseCase (with fakes) covering streaming accumulation, no-history, missing-exercise and missing-model error paths. Note: MediaPipe API tracks tasks-genai 0.10.x; the progress listener is treated as emitting incremental chunks. Verified with static review only — a full build needs the Android SDK (absent here). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012YaiFyXHFt67jzX79tgvAx --- IronMind/README.md | 35 ++++- IronMind/app/build.gradle.kts | 4 +- .../com/ironmind/app/data/ai/AiConstants.kt | 19 +++ .../app/data/ai/LlmInferenceManager.kt | 95 ++++++++++++ .../ironmind/app/data/local/dao/WorkoutDao.kt | 3 + .../data/repository/WorkoutRepositoryImpl.kt | 5 + .../main/java/com/ironmind/app/di/AiModule.kt | 19 +++ .../app/domain/ai/LlmInferenceService.kt | 24 +++ .../domain/ai/LlmModelNotFoundException.kt | 9 ++ .../app/domain/ai/ProgressionPromptBuilder.kt | 70 +++++++++ .../app/domain/model/SuggestionState.kt | 22 +++ .../domain/repository/WorkoutRepository.kt | 2 + .../GetProgressionSuggestionUseCase.kt | 69 +++++++++ .../GetProgressionSuggestionUseCaseTest.kt | 137 ++++++++++++++++++ .../app/ProgressionPromptBuilderTest.kt | 47 ++++++ 15 files changed, 550 insertions(+), 10 deletions(-) create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/ai/AiConstants.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/data/ai/LlmInferenceManager.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/di/AiModule.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmInferenceService.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmModelNotFoundException.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/ai/ProgressionPromptBuilder.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/model/SuggestionState.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/usecase/GetProgressionSuggestionUseCase.kt create mode 100644 IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt create mode 100644 IronMind/app/src/test/java/com/ironmind/app/ProgressionPromptBuilderTest.kt diff --git a/IronMind/README.md b/IronMind/README.md index b510d8c..d8078c6 100644 --- a/IronMind/README.md +++ b/IronMind/README.md @@ -25,19 +25,24 @@ accents in **gold `#FFD700`** and **cyan `#00FFFF`**. Tokens live in ## Roadmap -- **Stage 1 — Data & persistence (this commit).** Package structure, Room entities, relations, +- **Stage 1 — Data & persistence.** Package structure, Room entities, relations, DAO, DI wiring, seed catalog. ✅ -- **Stage 2 — Domain & presentation.** Use cases, MVVM ViewModels, state, navigation. -- **Stage 3 — UI + on-device AI.** Full glassmorphism screens and the streaming MediaPipe LLM. +- **Stage 2 — On-device AI.** MediaPipe LLM Inference (`LlmInferenceManager`) streaming a + `Flow`, `GetProgressionSuggestionUseCase` (Room history → coach prompt → progressive + overload suggestion), and a `SuggestionState` sealed class (Loading / Success / Error). ✅ +- **Stage 3 — UI.** Full glassmorphism screens, MVVM ViewModels, navigation, and the model + download/placement flow. ## Project structure (Clean Architecture) ``` com.ironmind.app ├── core/util # DispatcherProvider, Constants — cross-cutting helpers -├── domain # Pure Kotlin — no Android/Room dependencies -│ ├── model # Exercise, Routine, WorkoutSession, SetLog, enums, aggregates -│ └── repository # WorkoutRepository (the contract the app depends on) +├── domain # Pure Kotlin — no Android/Room/MediaPipe dependencies +│ ├── model # Exercise, Routine, WorkoutSession, SetLog, enums, SuggestionState +│ ├── ai # LlmInferenceService (port), ProgressionPromptBuilder, exceptions +│ ├── repository # WorkoutRepository (the contract the app depends on) +│ └── usecase # GetProgressionSuggestionUseCase ├── data # Implements the domain contracts │ ├── local │ │ ├── entity # @Entity: Exercise, Routine, RoutineExerciseCrossRef, Session, SetLog @@ -46,12 +51,26 @@ com.ironmind.app │ │ ├── dao # WorkoutDao (Flow reads, suspend writes, @Transaction relations) │ │ ├── seed # DefaultExercises — starter catalog on first launch │ │ └── IronMindDatabase.kt +│ ├── ai # LlmInferenceManager (MediaPipe), AiConstants │ ├── mapper # entity <-> domain mappers │ └── repository # WorkoutRepositoryImpl -├── di # Hilt modules: Database, Repository, Coroutines +├── di # Hilt modules: Database, Repository, Coroutines, Ai └── ui/theme # Compose design system (black + gold + cyan, glassmorphism) ``` +## On-device AI (Stage 2) + +- **`LlmInferenceManager`** (`data/ai`) implements the domain port **`LlmInferenceService`**. It + lazily loads the MediaPipe engine on `Dispatchers.IO`, opens a session per request, and exposes + the streaming response as a `Flow` via `callbackFlow` (typewriter effect). +- **`GetProgressionSuggestionUseCase`** (`domain/usecase`) reads the recent history for an + exercise from Room, builds a structured Spanish coaching prompt with + **`ProgressionPromptBuilder`**, streams the model's answer, and emits **`SuggestionState`** + (`Loading` → accumulating `Success` → final `Success` / `Error`). +- **Model file:** place a MediaPipe-compatible model (e.g. a Gemma `.bin`/`.task`) at + `filesDir/models/` (see `AiConstants.MODEL_FILE_NAME`). Kept out of the APK to stay small while + running fully offline. Missing model → a friendly `Error` state (`LlmModelNotFoundException`). + ## Data model - **ExerciseEntity** — catalog of exercises (name, muscle group, equipment, custom flag). @@ -72,7 +91,7 @@ Requires Android Studio (Ladybug+) or the Android SDK with `ANDROID_HOME` set. ```bash cd IronMind ./gradlew assembleDebug # build the APK -./gradlew testDebugUnitTest # JVM unit tests (mappers) +./gradlew testDebugUnitTest # JVM unit tests (mappers, prompt builder, suggestion use case) ./gradlew connectedDebugAndroidTest # instrumented Room DAO tests (device/emulator) ``` diff --git a/IronMind/app/build.gradle.kts b/IronMind/app/build.gradle.kts index f0c6623..de831a2 100644 --- a/IronMind/app/build.gradle.kts +++ b/IronMind/app/build.gradle.kts @@ -88,8 +88,8 @@ dependencies { // Coroutines implementation(libs.kotlinx.coroutines.android) - // On-device LLM — enabled in Stage 3 (Google AI Edge / MediaPipe LLM Inference API). - // implementation(libs.mediapipe.tasks.genai) + // On-device LLM (Google AI Edge / MediaPipe LLM Inference API) — Stage 2. + implementation(libs.mediapipe.tasks.genai) // Unit testing testImplementation(libs.junit) diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/ai/AiConstants.kt b/IronMind/app/src/main/java/com/ironmind/app/data/ai/AiConstants.kt new file mode 100644 index 0000000..2de8a53 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/ai/AiConstants.kt @@ -0,0 +1,19 @@ +package com.ironmind.app.data.ai + +/** Configuration for the on-device MediaPipe LLM Inference engine. */ +object AiConstants { + + /** + * Model file name expected under `filesDir/models/`. Any MediaPipe-compatible model works + * (e.g. a Gemma `.bin`/`.task` bundle). The download/placement step is handled by the UI in + * Stage 3; keeping it out of the APK keeps the binary small while staying 100% offline at + * runtime. Adjust to match the model you ship. + */ + const val MODEL_FILE_NAME = "gemma-2b-it-int4.bin" + const val MODEL_SUBDIR = "models" + + // Inference / sampling parameters. + const val MAX_TOKENS = 1024 + const val TOP_K = 40 + const val TEMPERATURE = 0.8f +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/ai/LlmInferenceManager.kt b/IronMind/app/src/main/java/com/ironmind/app/data/ai/LlmInferenceManager.kt new file mode 100644 index 0000000..30f84c7 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/ai/LlmInferenceManager.kt @@ -0,0 +1,95 @@ +package com.ironmind.app.data.ai + +import android.content.Context +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import com.google.mediapipe.tasks.genai.llminference.LlmInference.LlmInferenceOptions +import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession +import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.LlmInferenceSessionOptions +import com.ironmind.app.core.util.DispatcherProvider +import com.ironmind.app.domain.ai.LlmInferenceService +import com.ironmind.app.domain.ai.LlmModelNotFoundException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * On-device LLM manager backed by the Google AI Edge / MediaPipe LLM Inference API. + * + * The heavyweight [LlmInference] engine is created lazily (once) on [DispatcherProvider.io] and + * reused; a fresh [LlmInferenceSession] is created per request. Responses are exposed as a + * `Flow` of incremental chunks via [callbackFlow], producing the typewriter effect. + * + * Note: the MediaPipe API surface tracks the `tasks-genai` 0.10.x line; builder method names may + * need minor adjustment if you bump the dependency. The progress listener is treated as emitting + * *incremental* text — if a future version emits cumulative text, drop the concatenation upstream. + */ +@Singleton +class LlmInferenceManager @Inject constructor( + @ApplicationContext private val context: Context, + private val dispatchers: DispatcherProvider, +) : LlmInferenceService { + + private val initMutex = Mutex() + + @Volatile + private var engine: LlmInference? = null + + override suspend fun isModelAvailable(): Boolean = withContext(dispatchers.io) { + resolveModelFile().exists() + } + + override fun generateResponseStream(prompt: String): Flow = callbackFlow { + val llm = ensureEngine() + + val sessionOptions = LlmInferenceSessionOptions.builder() + .setTopK(AiConstants.TOP_K) + .setTemperature(AiConstants.TEMPERATURE) + .build() + + val session = LlmInferenceSession.createFromOptions(llm, sessionOptions) + session.addQueryChunk(prompt) + + // Asynchronous, streaming generation. `partialResult` is a new chunk; `done` ends the stream. + session.generateResponseAsync { partialResult, done -> + trySend(partialResult) + if (done) close() + } + + awaitClose { session.close() } + }.flowOn(dispatchers.io) + + override fun close() { + engine?.close() + engine = null + } + + /** Lazily creates the engine, guarded by a mutex so it initializes exactly once. */ + private suspend fun ensureEngine(): LlmInference { + engine?.let { return it } + return initMutex.withLock { + engine ?: createEngine().also { engine = it } + } + } + + private fun createEngine(): LlmInference { + val modelFile = resolveModelFile() + if (!modelFile.exists()) throw LlmModelNotFoundException(modelFile.absolutePath) + + val options = LlmInferenceOptions.builder() + .setModelPath(modelFile.absolutePath) + .setMaxTokens(AiConstants.MAX_TOKENS) + .build() + return LlmInference.createFromOptions(context, options) + } + + private fun resolveModelFile(): File = + File(File(context.filesDir, AiConstants.MODEL_SUBDIR), AiConstants.MODEL_FILE_NAME) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt b/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt index 95b2472..cb9acfb 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt @@ -125,6 +125,9 @@ interface WorkoutDao { @Query("SELECT * FROM set_logs WHERE exerciseId = :exerciseId ORDER BY performedAt DESC LIMIT 1") suspend fun getLastSetLogForExercise(exerciseId: Long): SetLogEntity? + @Query("SELECT * FROM set_logs WHERE exerciseId = :exerciseId ORDER BY performedAt DESC LIMIT :limit") + suspend fun getRecentSetLogsForExercise(exerciseId: Long, limit: Int): List + @Upsert suspend fun upsertSetLog(setLog: SetLogEntity): Long diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt index 88511e9..0fa3606 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt @@ -122,6 +122,11 @@ class WorkoutRepositoryImpl @Inject constructor( dao.getLastSetLogForExercise(exerciseId)?.toDomain() } + override suspend fun getRecentSetLogs(exerciseId: Long, limit: Int): List = + withContext(dispatchers.io) { + dao.getRecentSetLogsForExercise(exerciseId, limit).map { it.toDomain() } + } + override suspend fun upsertSetLog(setLog: SetLog): Long = withContext(dispatchers.io) { dao.upsertSetLog(setLog.toEntity()) } diff --git a/IronMind/app/src/main/java/com/ironmind/app/di/AiModule.kt b/IronMind/app/src/main/java/com/ironmind/app/di/AiModule.kt new file mode 100644 index 0000000..b388d77 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/di/AiModule.kt @@ -0,0 +1,19 @@ +package com.ironmind.app.di + +import com.ironmind.app.data.ai.LlmInferenceManager +import com.ironmind.app.domain.ai.LlmInferenceService +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** Binds the on-device AI port to its MediaPipe-backed implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class AiModule { + + @Binds + @Singleton + abstract fun bindLlmInferenceService(impl: LlmInferenceManager): LlmInferenceService +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmInferenceService.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmInferenceService.kt new file mode 100644 index 0000000..2be4562 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmInferenceService.kt @@ -0,0 +1,24 @@ +package com.ironmind.app.domain.ai + +import kotlinx.coroutines.flow.Flow + +/** + * Domain-facing port for the on-device large language model. The concrete implementation + * (MediaPipe LLM Inference / Google AI Edge) lives in the data layer, keeping the domain + * and use cases free of any framework dependency and easy to fake in tests. + */ +interface LlmInferenceService { + + /** `true` if the model file is present on the device (offline-ready). */ + suspend fun isModelAvailable(): Boolean + + /** + * Streams the model's response one chunk at a time (the "typewriter" effect). Each emission + * is an incremental piece of text; concatenating them yields the full response. The flow + * completes when generation is done and fails if the model is missing or inference errors. + */ + fun generateResponseStream(prompt: String): Flow + + /** Releases native resources held by the inference engine. */ + fun close() +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmModelNotFoundException.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmModelNotFoundException.kt new file mode 100644 index 0000000..7602e16 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/ai/LlmModelNotFoundException.kt @@ -0,0 +1,9 @@ +package com.ironmind.app.domain.ai + +/** + * Thrown when on-device inference is requested but the model file has not been provisioned yet. + * The UI can catch this (via the [com.ironmind.app.domain.model.SuggestionState.Error] state) to + * prompt the user to download/place the model. + */ +class LlmModelNotFoundException(val expectedPath: String) : + Exception("On-device model not found at: $expectedPath") diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/ai/ProgressionPromptBuilder.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/ai/ProgressionPromptBuilder.kt new file mode 100644 index 0000000..1e54d6b --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/ai/ProgressionPromptBuilder.kt @@ -0,0 +1,70 @@ +package com.ironmind.app.domain.ai + +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.SetLog +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Builds the structured prompt fed to the on-device LLM. It frames the model as a strength + * coach and lays out the athlete's recent history for the target exercise so the model can + * reason about progressive overload. Pure and deterministic — easy to unit test. + */ +object ProgressionPromptBuilder { + + private val dateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault()) + + /** + * @param exercise the exercise the suggestion is for. + * @param history recent set logs, expected most-recent-first. + * @param zoneId time zone used to render dates (injectable for testing). + */ + fun build( + exercise: Exercise, + history: List, + zoneId: ZoneId = ZoneId.systemDefault(), + ): String { + val historyBlock = if (history.isEmpty()) { + "Sin registros previos para este ejercicio." + } else { + history.joinToString(separator = "\n") { set -> formatSet(set, zoneId) } + } + + return buildString { + appendLine( + "Eres un entrenador personal de élite especializado en fuerza e hipertrofia. " + + "Aplicas el principio de sobrecarga progresiva de forma segura y basada en datos.", + ) + appendLine() + appendLine("Ejercicio: ${exercise.name} (${exercise.muscleGroup}, ${exercise.equipment}).") + appendLine() + appendLine("Historial reciente (más reciente primero, formato: fecha — peso x reps [RPE] · notas):") + appendLine(historyBlock) + appendLine() + appendLine( + "Con base EXCLUSIVAMENTE en estos datos, recomienda la carga para la próxima sesión:", + ) + appendLine("1. Peso objetivo (kg) y rango de repeticiones.") + appendLine("2. Una justificación breve (2-3 frases) citando la tendencia de volumen, el RPE") + appendLine(" y las notas (energía, suplementos, sensaciones) cuando sean relevantes.") + appendLine("3. Un consejo de seguridad o técnica si procede.") + appendLine() + append("Responde en español, en tono motivador y conciso.") + } + } + + private fun formatSet(set: SetLog, zoneId: ZoneId): String { + val date = dateFormatter.format(Instant.ofEpochMilli(set.performedAt).atZone(zoneId)) + val rpe = set.rpe?.let { " [RPE ${trimFloat(it)}]" }.orEmpty() + val warmup = if (set.isWarmup) " (calentamiento)" else "" + val notes = set.notes?.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty() + return "- $date — ${trimFloat(set.weightKg.toFloat())} kg x ${set.reps} reps$rpe$warmup$notes" + } + + /** Renders 80.0 as "80" and 82.5 as "82.5". */ + private fun trimFloat(value: Float): String = + if (value % 1f == 0f) value.toInt().toString() else value.toString() +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/model/SuggestionState.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/model/SuggestionState.kt new file mode 100644 index 0000000..f90fd9f --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/model/SuggestionState.kt @@ -0,0 +1,22 @@ +package com.ironmind.app.domain.model + +/** + * UI-facing state for an AI progression suggestion. As tokens stream in, the use case emits + * [Success] repeatedly with the text accumulated so far ([isComplete] = false), then a final + * [Success] with [isComplete] = true. This drives the typewriter effect while keeping the + * classic Loading / Success / Error shape. + */ +sealed interface SuggestionState { + + /** The prompt has been built and inference is starting; nothing has streamed yet. */ + data object Loading : SuggestionState + + /** Response text so far. When [isComplete] is true the stream has finished. */ + data class Success( + val suggestion: String, + val isComplete: Boolean = false, + ) : SuggestionState + + /** Something went wrong (model missing, no history, inference failure, …). */ + data class Error(val message: String) : SuggestionState +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt index f0ac527..7786c42 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt @@ -54,6 +54,8 @@ interface WorkoutRepository { fun observeSetLogsForExercise(exerciseId: Long): Flow> /** The most recent logged set for an exercise — the "last time" reference for progression. */ suspend fun getLastSetLogForExercise(exerciseId: Long): SetLog? + /** The most recent [limit] set logs for an exercise (newest first) — the AI history window. */ + suspend fun getRecentSetLogs(exerciseId: Long, limit: Int): List suspend fun upsertSetLog(setLog: SetLog): Long suspend fun deleteSetLog(setLog: SetLog) } diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/usecase/GetProgressionSuggestionUseCase.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/usecase/GetProgressionSuggestionUseCase.kt new file mode 100644 index 0000000..1f79864 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/usecase/GetProgressionSuggestionUseCase.kt @@ -0,0 +1,69 @@ +package com.ironmind.app.domain.usecase + +import com.ironmind.app.domain.ai.LlmInferenceService +import com.ironmind.app.domain.ai.LlmModelNotFoundException +import com.ironmind.app.domain.ai.ProgressionPromptBuilder +import com.ironmind.app.domain.model.SuggestionState +import com.ironmind.app.domain.repository.WorkoutRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Generates an AI progressive-overload suggestion for an exercise, fully on-device. + * + * Pipeline: pull the recent history from Room → build a structured coach prompt → + * stream the model's answer, re-emitting the accumulated text so the UI can render a + * typewriter effect. The whole flow is expressed as [SuggestionState] (Loading / Success / Error). + */ +class GetProgressionSuggestionUseCase @Inject constructor( + private val repository: WorkoutRepository, + private val llmInferenceService: LlmInferenceService, +) { + + operator fun invoke( + exerciseId: Long, + historyLimit: Int = DEFAULT_HISTORY_LIMIT, + ): Flow = flow { + emit(SuggestionState.Loading) + + val exercise = repository.getExercise(exerciseId) + if (exercise == null) { + emit(SuggestionState.Error("No se encontró el ejercicio.")) + return@flow + } + + val history = repository.getRecentSetLogs(exerciseId, historyLimit) + if (history.isEmpty()) { + emit(SuggestionState.Error("Aún no hay registros de este ejercicio para analizar.")) + return@flow + } + + val prompt = ProgressionPromptBuilder.build(exercise, history) + + // Stream tokens, re-emitting the accumulated text (typewriter), then a final complete state. + val accumulated = StringBuilder() + emitAll( + llmInferenceService.generateResponseStream(prompt).map { chunk -> + accumulated.append(chunk) + SuggestionState.Success(accumulated.toString(), isComplete = false) + }, + ) + emit(SuggestionState.Success(accumulated.toString(), isComplete = true)) + }.catch { throwable -> + val message = when (throwable) { + is LlmModelNotFoundException -> + "El modelo de IA no está disponible en el dispositivo. Descárgalo para continuar." + else -> throwable.message ?: "Ocurrió un error al generar la sugerencia." + } + emit(SuggestionState.Error(message)) + } + + companion object { + /** How many recent sets to feed the model as context. */ + const val DEFAULT_HISTORY_LIMIT = 15 + } +} diff --git a/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt b/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt new file mode 100644 index 0000000..5246ef9 --- /dev/null +++ b/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt @@ -0,0 +1,137 @@ +package com.ironmind.app + +import com.ironmind.app.domain.ai.LlmInferenceService +import com.ironmind.app.domain.ai.LlmModelNotFoundException +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.Routine +import com.ironmind.app.domain.model.RoutinePlan +import com.ironmind.app.domain.model.SessionDetail +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.domain.model.SuggestionState +import com.ironmind.app.domain.model.WorkoutSession +import com.ironmind.app.domain.repository.WorkoutRepository +import com.ironmind.app.domain.usecase.GetProgressionSuggestionUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GetProgressionSuggestionUseCaseTest { + + private val sampleExercise = Exercise( + id = 1, name = "Back Squat", muscleGroup = MuscleGroup.QUADS, equipment = Equipment.BARBELL, + ) + private val sampleSet = SetLog( + id = 1, sessionId = 1, exerciseId = 1, setNumber = 1, weightKg = 100.0, reps = 5, + ) + + @Test + fun invoke_streamsAccumulatedTextThenCompletes() = runTest { + val repo = FakeWorkoutRepository(exercise = sampleExercise, recent = listOf(sampleSet)) + val llm = FakeLlmInferenceService(chunks = listOf("Sube ", "2.5 ", "kg")) + val useCase = GetProgressionSuggestionUseCase(repo, llm) + + val states = useCase(exerciseId = 1).toList() + + assertEquals(SuggestionState.Loading, states.first()) + // Intermediate Success emissions accumulate the streamed text (typewriter effect). + assertEquals(SuggestionState.Success("Sube ", isComplete = false), states[1]) + val last = states.last() + assertTrue(last is SuggestionState.Success && last.isComplete) + assertEquals("Sube 2.5 kg", (last as SuggestionState.Success).suggestion) + } + + @Test + fun invoke_emitsErrorWhenNoHistory() = runTest { + val repo = FakeWorkoutRepository(exercise = sampleExercise, recent = emptyList()) + val useCase = GetProgressionSuggestionUseCase(repo, FakeLlmInferenceService()) + + val states = useCase(1).toList() + + assertEquals(SuggestionState.Loading, states.first()) + assertTrue(states.last() is SuggestionState.Error) + } + + @Test + fun invoke_emitsErrorWhenExerciseMissing() = runTest { + val repo = FakeWorkoutRepository(exercise = null, recent = emptyList()) + val useCase = GetProgressionSuggestionUseCase(repo, FakeLlmInferenceService()) + + val states = useCase(99).toList() + + assertTrue(states.last() is SuggestionState.Error) + } + + @Test + fun invoke_mapsModelNotFoundToFriendlyError() = runTest { + val repo = FakeWorkoutRepository(exercise = sampleExercise, recent = listOf(sampleSet)) + val llm = FakeLlmInferenceService(error = LlmModelNotFoundException("/models/x.bin")) + val useCase = GetProgressionSuggestionUseCase(repo, llm) + + val last = useCase(1).toList().last() + + assertTrue(last is SuggestionState.Error) + assertTrue((last as SuggestionState.Error).message.contains("modelo", ignoreCase = true)) + } +} + +// ---- Test doubles ------------------------------------------------------------------------- + +private class FakeLlmInferenceService( + private val chunks: List = emptyList(), + private val error: Throwable? = null, + private val modelAvailable: Boolean = true, +) : LlmInferenceService { + override suspend fun isModelAvailable(): Boolean = modelAvailable + override fun generateResponseStream(prompt: String): Flow = flow { + error?.let { throw it } + chunks.forEach { emit(it) } + } + override fun close() = Unit +} + +private class FakeWorkoutRepository( + private val exercise: Exercise?, + private val recent: List, +) : WorkoutRepository { + + override suspend fun getExercise(id: Long): Exercise? = exercise + override suspend fun getRecentSetLogs(exerciseId: Long, limit: Int): List = recent + + // --- Unused in these tests: sensible no-op defaults --- + override fun observeExercises(): Flow> = emptyFlow() + override fun observeExercisesByMuscleGroup(muscleGroup: MuscleGroup): Flow> = emptyFlow() + override fun searchExercises(query: String): Flow> = emptyFlow() + override suspend fun upsertExercise(exercise: Exercise): Long = 0 + override suspend fun deleteExercise(exercise: Exercise) = Unit + override fun observeRoutines(): Flow> = emptyFlow() + override fun observeRoutinePlans(): Flow> = emptyFlow() + override fun observeRoutinePlan(routineId: Long): Flow = emptyFlow() + override suspend fun upsertRoutine(routine: Routine): Long = 0 + override suspend fun deleteRoutine(routine: Routine) = Unit + override suspend fun addExerciseToRoutine( + routineId: Long, + exerciseId: Long, + position: Int, + targetSets: Int, + targetReps: Int, + targetRestSeconds: Int, + ) = Unit + override suspend fun removeExerciseFromRoutine(routineId: Long, exerciseId: Long) = Unit + override fun observeSessions(): Flow> = emptyFlow() + override fun observeSessionDetails(): Flow> = emptyFlow() + override fun observeSessionDetail(sessionId: Long): Flow = emptyFlow() + override suspend fun startSession(session: WorkoutSession): Long = 0 + override suspend fun updateSession(session: WorkoutSession) = Unit + override suspend fun deleteSession(session: WorkoutSession) = Unit + override fun observeSetLogsForExercise(exerciseId: Long): Flow> = emptyFlow() + override suspend fun getLastSetLogForExercise(exerciseId: Long): SetLog? = null + override suspend fun upsertSetLog(setLog: SetLog): Long = 0 + override suspend fun deleteSetLog(setLog: SetLog) = Unit +} diff --git a/IronMind/app/src/test/java/com/ironmind/app/ProgressionPromptBuilderTest.kt b/IronMind/app/src/test/java/com/ironmind/app/ProgressionPromptBuilderTest.kt new file mode 100644 index 0000000..9a3cde4 --- /dev/null +++ b/IronMind/app/src/test/java/com/ironmind/app/ProgressionPromptBuilderTest.kt @@ -0,0 +1,47 @@ +package com.ironmind.app + +import com.ironmind.app.domain.ai.ProgressionPromptBuilder +import com.ironmind.app.domain.model.Equipment +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.MuscleGroup +import com.ironmind.app.domain.model.SetLog +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneId + +/** Unit tests for the on-device prompt construction. */ +class ProgressionPromptBuilderTest { + + private val exercise = Exercise( + id = 1, + name = "Barbell Bench Press", + muscleGroup = MuscleGroup.CHEST, + equipment = Equipment.BARBELL, + ) + + @Test + fun build_includesExerciseNameAndHistoryDetails() { + val history = listOf( + SetLog( + id = 2, sessionId = 1, exerciseId = 1, setNumber = 1, + weightKg = 80.0, reps = 8, rpe = 8f, + notes = "Energía 9/10, ON Gold Standard Whey", + performedAt = 1_726_000_000_000L, + ), + ) + + val prompt = ProgressionPromptBuilder.build(exercise, history, ZoneId.of("UTC")) + + assertTrue(prompt.contains("Barbell Bench Press")) + assertTrue(prompt.contains("80 kg x 8 reps")) + assertTrue(prompt.contains("RPE 8")) + assertTrue(prompt.contains("ON Gold Standard Whey")) + assertTrue(prompt.contains("sobrecarga progresiva")) + } + + @Test + fun build_handlesEmptyHistoryGracefully() { + val prompt = ProgressionPromptBuilder.build(exercise, emptyList(), ZoneId.of("UTC")) + assertTrue(prompt.contains("Sin registros previos")) + } +} From 2d77e818e55d59e590efa1a41888b5b0f9aca3b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 02:00:40 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(ironmind):=20Stage=203=20=E2=80=94=20p?= =?UTF-8?q?remium=20Compose=20UI=20(dashboard,=20session,=20progress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the full Jetpack Compose interface on the dark glassmorphism system, wired to Room and the on-device AI via Hilt MVVM ViewModels. Theme & components: - Add Shapes to the theme; reusable components: GlassCard (frosted fill + gold→cyan glowing edge), GlowProgressBar, StatTile, SectionTitle, AccentButton, LabeledValue. - Navigation-compose graph (Dashboard → Session / Progress). Screens + ViewModels (StateFlow + collectAsStateWithLifecycle): - Dashboard: training streak (StreakCalculator), live on-device AI suggestion panel (streamed), routine cards with weekly-progress bars. - Session tracking: pick exercise, log weight × reps in real time, sets grouped per exercise, rest timer with 60/90/120s presets (coroutine-driven). - Progress analysis: interactive LoadChart (Canvas) — cyan curve with glow + area fill over a gold field, tap-to-select a point — plus best mark and detailed history. Wiring & support: - DashboardViewModel / SessionViewModel / ProgressViewModel read Room Flows and the GetProgressionSuggestionUseCase; SavedStateHandle carries nav args. - Add WorkoutRepository.getSession(id) for finishing a session. - StreakCalculator (domain) + unit tests. - MainActivity now hosts the NavHost. Note: static review only — a full build/render needs the Android SDK (absent here). MediaPipe API still tracks tasks-genai 0.10.x. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012YaiFyXHFt67jzX79tgvAx --- IronMind/README.md | 26 +- IronMind/app/build.gradle.kts | 1 + .../java/com/ironmind/app/MainActivity.kt | 77 +----- .../data/repository/WorkoutRepositoryImpl.kt | 4 + .../domain/repository/WorkoutRepository.kt | 1 + .../app/domain/util/StreakCalculator.kt | 38 +++ .../app/ui/components/CommonComponents.kt | 100 +++++++ .../ironmind/app/ui/components/GlassCard.kt | 45 +++ .../app/ui/components/GlowProgressBar.kt | 47 ++++ .../app/ui/dashboard/DashboardScreen.kt | 225 +++++++++++++++ .../app/ui/dashboard/DashboardUiState.kt | 21 ++ .../app/ui/dashboard/DashboardViewModel.kt | 83 ++++++ .../app/ui/navigation/Destinations.kt | 22 ++ .../app/ui/navigation/IronMindNavHost.kt | 49 ++++ .../com/ironmind/app/ui/progress/LoadChart.kt | 104 +++++++ .../app/ui/progress/ProgressScreen.kt | 151 ++++++++++ .../app/ui/progress/ProgressUiState.kt | 19 ++ .../app/ui/progress/ProgressViewModel.kt | 85 ++++++ .../ironmind/app/ui/session/SessionScreen.kt | 257 ++++++++++++++++++ .../ironmind/app/ui/session/SessionUiState.kt | 22 ++ .../app/ui/session/SessionViewModel.kt | 153 +++++++++++ .../java/com/ironmind/app/ui/theme/Shape.kt | 14 + .../java/com/ironmind/app/ui/theme/Theme.kt | 1 + .../GetProgressionSuggestionUseCaseTest.kt | 1 + .../com/ironmind/app/StreakCalculatorTest.kt | 45 +++ IronMind/gradle/libs.versions.toml | 2 + 26 files changed, 1516 insertions(+), 77 deletions(-) create mode 100644 IronMind/app/src/main/java/com/ironmind/app/domain/util/StreakCalculator.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/components/CommonComponents.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/components/GlassCard.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/components/GlowProgressBar.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardScreen.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardUiState.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardViewModel.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/navigation/Destinations.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/navigation/IronMindNavHost.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/progress/LoadChart.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressScreen.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressUiState.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressViewModel.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionScreen.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionUiState.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionViewModel.kt create mode 100644 IronMind/app/src/main/java/com/ironmind/app/ui/theme/Shape.kt create mode 100644 IronMind/app/src/test/java/com/ironmind/app/StreakCalculatorTest.kt diff --git a/IronMind/README.md b/IronMind/README.md index d8078c6..6ecedd5 100644 --- a/IronMind/README.md +++ b/IronMind/README.md @@ -30,8 +30,10 @@ accents in **gold `#FFD700`** and **cyan `#00FFFF`**. Tokens live in - **Stage 2 — On-device AI.** MediaPipe LLM Inference (`LlmInferenceManager`) streaming a `Flow`, `GetProgressionSuggestionUseCase` (Room history → coach prompt → progressive overload suggestion), and a `SuggestionState` sealed class (Loading / Success / Error). ✅ -- **Stage 3 — UI.** Full glassmorphism screens, MVVM ViewModels, navigation, and the model - download/placement flow. +- **Stage 3 — Premium UI (Jetpack Compose).** Theme (colors/typography/shapes), reusable + glassmorphism components, and three screens — Dashboard (streak, AI panel, routine progress), + Session tracking (real-time set logging + rest timer), Progress analysis (interactive cyan-on-gold + load chart + history) — wired to Room and the local-AI flow via Hilt MVVM ViewModels. ✅ ## Project structure (Clean Architecture) @@ -55,9 +57,27 @@ com.ironmind.app │ ├── mapper # entity <-> domain mappers │ └── repository # WorkoutRepositoryImpl ├── di # Hilt modules: Database, Repository, Coroutines, Ai -└── ui/theme # Compose design system (black + gold + cyan, glassmorphism) +└── ui + ├── theme # Compose design system (black + gold + cyan, glassmorphism) + ├── components # GlassCard, GlowProgressBar, StatTile, AccentButton, … + ├── navigation # NavHost + routes + ├── dashboard # DashboardScreen + DashboardViewModel + ├── session # SessionScreen + SessionViewModel (set logging + rest timer) + └── progress # ProgressScreen + ProgressViewModel + LoadChart (Canvas) ``` +## Screens (Stage 3) + +- **Dashboard** — training streak, a live AI suggestion panel (on-device, streamed), and routine + cards with weekly-progress bars. +- **Session tracking** — pick an exercise, log weight × reps in real time, auto-starting a + rest timer (60/90/120s presets); sets grouped per exercise. +- **Progress analysis** — an interactive `LoadChart` (Canvas): a cyan curve with glow + area fill + over a gold field, tap-to-select a point, plus a best-mark stat and detailed history. + +All three are driven by Hilt `@HiltViewModel` ViewModels exposing `StateFlow<…UiState>` collected +with `collectAsStateWithLifecycle()`, reactively backed by Room `Flow`s and the AI use case. + ## On-device AI (Stage 2) - **`LlmInferenceManager`** (`data/ai`) implements the domain port **`LlmInferenceService`**. It diff --git a/IronMind/app/build.gradle.kts b/IronMind/app/build.gradle.kts index de831a2..4969b02 100644 --- a/IronMind/app/build.gradle.kts +++ b/IronMind/app/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) + implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.ui.tooling) // Room (persistence) diff --git a/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt b/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt index 7f1abbb..c488c8a 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt @@ -4,35 +4,15 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.ironmind.app.ui.theme.Cyan -import com.ironmind.app.ui.theme.GlassFill -import com.ironmind.app.ui.theme.Gold +import com.ironmind.app.ui.navigation.IronMindNavHost import com.ironmind.app.ui.theme.IronMindTheme -import com.ironmind.app.ui.theme.TextMuted import dagger.hilt.android.AndroidEntryPoint -/** - * Single-activity host. The real navigation and screens land in Stage 3; for now this shows - * a themed placeholder so the app builds, runs, and previews the design language. - */ +/** Single-activity host for the whole Compose UI. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -44,60 +24,9 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - StageOnePlaceholder() + IronMindNavHost() } } } } } - -@Composable -private fun StageOnePlaceholder(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - // Frosted "glass" card with a subtle gold→cyan glowing edge. - Column( - modifier = Modifier - .background(GlassFill, RoundedCornerShape(24.dp)) - .border( - width = 1.dp, - brush = Brush.linearGradient( - listOf(Gold.copy(alpha = 0.6f), Cyan.copy(alpha = 0.4f)), - ), - shape = RoundedCornerShape(24.dp), - ) - .padding(horizontal = 28.dp, vertical = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "IronMind", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - color = Gold, - ) - Text( - text = "Etapa 1 · Capa de datos lista", - style = MaterialTheme.typography.titleLarge, - color = Cyan, - ) - Text( - text = "Room · Clean Architecture · Hilt\n100% offline · IA on-device (próximamente)", - style = MaterialTheme.typography.bodyLarge, - color = TextMuted, - ) - } - } -} - -@Preview(showBackground = true, backgroundColor = 0xFF000000) -@Composable -private fun StageOnePlaceholderPreview() { - IronMindTheme { - StageOnePlaceholder() - } -} diff --git a/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt index 0fa3606..b76118b 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt @@ -102,6 +102,10 @@ class WorkoutRepositoryImpl @Inject constructor( override fun observeSessionDetail(sessionId: Long): Flow = dao.observeSessionWithSets(sessionId).map { it?.toDomain() } + override suspend fun getSession(id: Long): WorkoutSession? = withContext(dispatchers.io) { + dao.getSessionById(id)?.toDomain() + } + override suspend fun startSession(session: WorkoutSession): Long = withContext(dispatchers.io) { dao.insertSession(session.toEntity()) } diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt index 7786c42..75089a3 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt @@ -47,6 +47,7 @@ interface WorkoutRepository { fun observeSessions(): Flow> fun observeSessionDetails(): Flow> fun observeSessionDetail(sessionId: Long): Flow + suspend fun getSession(id: Long): WorkoutSession? suspend fun startSession(session: WorkoutSession): Long suspend fun updateSession(session: WorkoutSession) suspend fun deleteSession(session: WorkoutSession) diff --git a/IronMind/app/src/main/java/com/ironmind/app/domain/util/StreakCalculator.kt b/IronMind/app/src/main/java/com/ironmind/app/domain/util/StreakCalculator.kt new file mode 100644 index 0000000..4705a95 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/util/StreakCalculator.kt @@ -0,0 +1,38 @@ +package com.ironmind.app.domain.util + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Computes the current training streak: the number of consecutive days (ending today, or + * yesterday if today hasn't been trained yet) on which at least one session was started. + * Pure and deterministic — [today] and [zoneId] are injectable for testing. + */ +object StreakCalculator { + + fun currentStreak( + sessionStartMillis: List, + today: LocalDate = LocalDate.now(), + zoneId: ZoneId = ZoneId.systemDefault(), + ): Int { + if (sessionStartMillis.isEmpty()) return 0 + + val trainedDays = sessionStartMillis + .map { Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate() } + .toSet() + + var anchor = when { + trainedDays.contains(today) -> today + trainedDays.contains(today.minusDays(1)) -> today.minusDays(1) + else -> return 0 + } + + var streak = 0 + while (trainedDays.contains(anchor)) { + streak++ + anchor = anchor.minusDays(1) + } + return streak + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/components/CommonComponents.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/components/CommonComponents.kt new file mode 100644 index 0000000..01d0dd3 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/components/CommonComponents.kt @@ -0,0 +1,100 @@ +package com.ironmind.app.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ironmind.app.ui.theme.Black +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold +import com.ironmind.app.ui.theme.TextMuted + +/** A screen/section heading with an accent tick. */ +@Composable +fun SectionTitle(text: String, modifier: Modifier = Modifier, accent: androidx.compose.ui.graphics.Color = Gold) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = "▍", + color = accent, + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = text, + modifier = Modifier.padding(start = 6.dp), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } +} + +/** A big number over a caption — used for the streak and quick stats. */ +@Composable +fun StatTile( + value: String, + label: String, + modifier: Modifier = Modifier, + valueColor: androidx.compose.ui.graphics.Color = Cyan, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = value, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = valueColor, + ) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = TextMuted, + ) + } +} + +/** The primary call-to-action button in brand gold. */ +@Composable +fun AccentButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = Gold, + contentColor = Black, + disabledContainerColor = Gold.copy(alpha = 0.3f), + disabledContentColor = Black.copy(alpha = 0.5f), + ), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } +} + +/** A label/value row used inside detail cards. */ +@Composable +fun LabeledValue(label: String, value: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, color = TextMuted) + Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/components/GlassCard.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/components/GlassCard.kt new file mode 100644 index 0000000..a661a76 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/components/GlassCard.kt @@ -0,0 +1,45 @@ +package com.ironmind.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold + +/** + * The signature "frosted glass" surface: a subtle white vertical gradient fill with a soft, + * glowing gold→cyan gradient edge. Used for every immersive card in the app. + */ +@Composable +fun GlassCard( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(20.dp), + borderBrush: Brush = Brush.linearGradient( + listOf(Gold.copy(alpha = 0.55f), Cyan.copy(alpha = 0.35f)), + ), + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(24.dp) + Column( + modifier = modifier + .clip(shape) + .background( + Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.07f), Color.White.copy(alpha = 0.02f)), + ), + ) + .border(width = 1.dp, brush = borderBrush, shape = shape) + .padding(contentPadding), + content = content, + ) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/components/GlowProgressBar.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/components/GlowProgressBar.kt new file mode 100644 index 0000000..f60977a --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/components/GlowProgressBar.kt @@ -0,0 +1,47 @@ +package com.ironmind.app.ui.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold + +/** A slim, rounded progress bar with a gold→cyan gradient fill, animated on change. */ +@Composable +fun GlowProgressBar( + progress: Float, + modifier: Modifier = Modifier, + height: Dp = 8.dp, +) { + val animated by animateFloatAsState( + targetValue = progress.coerceIn(0f, 1f), + label = "progress", + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(height) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.08f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(animated) + .fillMaxHeight() + .clip(CircleShape) + .background(Brush.horizontalGradient(listOf(Gold, Cyan))), + ) + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardScreen.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..369eb78 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardScreen.kt @@ -0,0 +1,225 @@ +package com.ironmind.app.ui.dashboard + +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ironmind.app.domain.model.SuggestionState +import com.ironmind.app.ui.components.AccentButton +import com.ironmind.app.ui.components.GlassCard +import com.ironmind.app.ui.components.GlowProgressBar +import com.ironmind.app.ui.components.SectionTitle +import com.ironmind.app.ui.components.StatTile +import com.ironmind.app.ui.theme.Black +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold +import com.ironmind.app.ui.theme.TextMuted +import androidx.compose.ui.graphics.Color + +private val ErrorRed = Color(0xFFFF6B6B) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + onStartSession: (routineId: Long) -> Unit, + onOpenProgress: (exerciseId: Long) -> Unit, + viewModel: DashboardViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val suggestion by viewModel.suggestion.collectAsStateWithLifecycle() + + Scaffold( + containerColor = Black, + topBar = { + CenterAlignedTopAppBar( + title = { Text("IronMind", color = Gold, fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Black), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { StatsRow(streak = state.streak, totalSessions = state.totalSessions) } + + item { + AiSuggestionPanel( + focusExerciseId = state.focusExerciseId, + focusExerciseName = state.focusExerciseName, + suggestion = suggestion, + onGenerate = viewModel::generateSuggestion, + onDismiss = viewModel::dismissSuggestion, + onOpenProgress = onOpenProgress, + ) + } + + item { SectionTitle("Rutinas") } + + if (state.routines.isEmpty()) { + item { + GlassCard { + Text( + "Aún no tienes rutinas. Empieza una sesión libre y registra tus ejercicios.", + color = TextMuted, + ) + } + } + } + + items(state.routines, key = { it.routineId }) { routine -> + RoutineCard(routine = routine, onStart = { onStartSession(routine.routineId) }) + } + + item { + AccentButton( + text = "Iniciar sesión libre", + onClick = { onStartSession(0L) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun StatsRow(streak: Int, totalSessions: Int) { + GlassCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + StatTile(value = "🔥 $streak", label = "Racha (días)", modifier = Modifier.weight(1f), valueColor = Gold) + StatTile(value = "$totalSessions", label = "Sesiones", modifier = Modifier.weight(1f), valueColor = Cyan) + } + } +} + +@Composable +private fun AiSuggestionPanel( + focusExerciseId: Long?, + focusExerciseName: String?, + suggestion: SuggestionState?, + onGenerate: (Long) -> Unit, + onDismiss: () -> Unit, + onOpenProgress: (Long) -> Unit, +) { + GlassCard( + borderBrush = androidx.compose.ui.graphics.Brush.linearGradient( + listOf(Cyan.copy(alpha = 0.55f), Gold.copy(alpha = 0.35f)), + ), + ) { + SectionTitle("Coach IA", accent = Cyan) + Spacer(Modifier.height(12.dp)) + + if (focusExerciseId == null) { + Text( + "Registra un entrenamiento para recibir sugerencias de sobrecarga progresiva generadas en tu dispositivo.", + color = TextMuted, + ) + return@GlassCard + } + + Text("Enfoque: ${focusExerciseName ?: "tu último ejercicio"}", color = Gold, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(12.dp)) + + when (val s = suggestion) { + null -> AccentButton( + text = "Generar sugerencia", + onClick = { onGenerate(focusExerciseId) }, + ) + + SuggestionState.Loading -> Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(color = Cyan, strokeWidth = 2.dp, modifier = Modifier.height(20.dp)) + Spacer(Modifier.height(0.dp)) + Text(" Analizando tu progreso…", color = TextMuted) + } + + is SuggestionState.Success -> Column { + Text(s.suggestion, color = Color.White) + if (s.isComplete) { + Row { + TextButton(onClick = { onGenerate(focusExerciseId) }) { Text("Regenerar", color = Cyan) } + TextButton(onClick = onDismiss) { Text("Cerrar", color = TextMuted) } + } + } + } + + is SuggestionState.Error -> Column { + Text(s.message, color = ErrorRed) + TextButton(onClick = { onGenerate(focusExerciseId) }) { Text("Reintentar", color = Cyan) } + } + } + + TextButton(onClick = { onOpenProgress(focusExerciseId) }) { + Text("Ver progreso de este ejercicio", color = Cyan) + } + } +} + +@Composable +private fun RoutineCard(routine: RoutineProgressUi, onStart: () -> Unit) { + GlassCard { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(routine.name, style = androidx.compose.material3.MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + SplitChip(label = routine.split.name) + } + Text("${routine.exerciseCount} ejercicios", color = TextMuted) + } + Spacer(Modifier.height(12.dp)) + GlowProgressBar(progress = routine.progress) + Spacer(Modifier.height(4.dp)) + Text( + "${(routine.progress * 100).toInt()}% entrenado esta semana", + color = TextMuted, + style = androidx.compose.material3.MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.height(12.dp)) + AccentButton(text = "Iniciar", onClick = onStart) + } +} + +@Composable +private fun SplitChip(label: String) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .border(1.dp, Cyan.copy(alpha = 0.6f), RoundedCornerShape(50)) + .padding(horizontal = 10.dp, vertical = 2.dp), + ) { + Text(label, color = Cyan, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardUiState.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardUiState.kt new file mode 100644 index 0000000..ce4bbeb --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardUiState.kt @@ -0,0 +1,21 @@ +package com.ironmind.app.ui.dashboard + +import com.ironmind.app.domain.model.RoutineSplit + +/** A routine summarized for the dashboard, with a 7-day training-progress fraction. */ +data class RoutineProgressUi( + val routineId: Long, + val name: String, + val split: RoutineSplit, + val exerciseCount: Int, + val progress: Float, +) + +data class DashboardUiState( + val isLoading: Boolean = true, + val streak: Int = 0, + val totalSessions: Int = 0, + val routines: List = emptyList(), + val focusExerciseId: Long? = null, + val focusExerciseName: String? = null, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardViewModel.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..f22c708 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/dashboard/DashboardViewModel.kt @@ -0,0 +1,83 @@ +package com.ironmind.app.ui.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ironmind.app.domain.model.SuggestionState +import com.ironmind.app.domain.repository.WorkoutRepository +import com.ironmind.app.domain.usecase.GetProgressionSuggestionUseCase +import com.ironmind.app.domain.util.StreakCalculator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.days + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val repository: WorkoutRepository, + private val getProgressionSuggestion: GetProgressionSuggestionUseCase, +) : ViewModel() { + + val uiState: StateFlow = combine( + repository.observeExercises(), + repository.observeRoutinePlans(), + repository.observeSessionDetails(), + ) { exercises, plans, sessions -> + val exerciseNames = exercises.associate { it.id to it.name } + val weekAgo = System.currentTimeMillis() - 7.days.inWholeMilliseconds + val trainedThisWeek = sessions + .filter { it.session.startedAt >= weekAgo } + .flatMap { detail -> detail.sets.map { it.exerciseId } } + .toSet() + + val latestSet = sessions.firstOrNull()?.sets?.maxByOrNull { it.performedAt } + + DashboardUiState( + isLoading = false, + streak = StreakCalculator.currentStreak(sessions.map { it.session.startedAt }), + totalSessions = sessions.size, + routines = plans.map { plan -> + val count = plan.exercises.size + RoutineProgressUi( + routineId = plan.routine.id, + name = plan.routine.name, + split = plan.routine.split, + exerciseCount = count, + progress = if (count == 0) 0f else { + plan.exercises.count { it.id in trainedThisWeek } / count.toFloat() + }, + ) + }, + focusExerciseId = latestSet?.exerciseId, + focusExerciseName = latestSet?.let { exerciseNames[it.exerciseId] }, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DashboardUiState(), + ) + + private val _suggestion = MutableStateFlow(null) + val suggestion: StateFlow = _suggestion.asStateFlow() + + private var suggestionJob: Job? = null + + /** Streams an AI progressive-overload suggestion for [exerciseId] into [suggestion]. */ + fun generateSuggestion(exerciseId: Long) { + suggestionJob?.cancel() + suggestionJob = viewModelScope.launch { + getProgressionSuggestion(exerciseId).collect { state -> _suggestion.value = state } + } + } + + fun dismissSuggestion() { + suggestionJob?.cancel() + _suggestion.value = null + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/navigation/Destinations.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/navigation/Destinations.kt new file mode 100644 index 0000000..7ccc107 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/navigation/Destinations.kt @@ -0,0 +1,22 @@ +package com.ironmind.app.ui.navigation + +/** Central definition of navigation routes and their arguments. */ +object Destinations { + + const val DASHBOARD = "dashboard" + + const val ARG_SESSION_ID = "sessionId" + const val ARG_ROUTINE_ID = "routineId" + const val ARG_EXERCISE_ID = "exerciseId" + + const val SESSION_ROUTE = "session?$ARG_SESSION_ID={$ARG_SESSION_ID}&$ARG_ROUTINE_ID={$ARG_ROUTINE_ID}" + const val PROGRESS_ROUTE = "progress?$ARG_EXERCISE_ID={$ARG_EXERCISE_ID}" + + /** Builds a session route. sessionId=0 means "start a new session" (optionally from routineId). */ + fun session(sessionId: Long = 0L, routineId: Long = 0L): String = + "session?$ARG_SESSION_ID=$sessionId&$ARG_ROUTINE_ID=$routineId" + + /** Builds a progress route. exerciseId=0 lets the screen pick the most recently trained exercise. */ + fun progress(exerciseId: Long = 0L): String = + "progress?$ARG_EXERCISE_ID=$exerciseId" +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/navigation/IronMindNavHost.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/navigation/IronMindNavHost.kt new file mode 100644 index 0000000..9cd7998 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/navigation/IronMindNavHost.kt @@ -0,0 +1,49 @@ +package com.ironmind.app.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.ironmind.app.ui.dashboard.DashboardScreen +import com.ironmind.app.ui.progress.ProgressScreen +import com.ironmind.app.ui.session.SessionScreen + +/** Root navigation graph: Dashboard → Session / Progress. */ +@Composable +fun IronMindNavHost(navController: NavHostController = rememberNavController()) { + NavHost(navController = navController, startDestination = Destinations.DASHBOARD) { + + composable(Destinations.DASHBOARD) { + DashboardScreen( + onStartSession = { routineId -> + navController.navigate(Destinations.session(routineId = routineId)) + }, + onOpenProgress = { exerciseId -> + navController.navigate(Destinations.progress(exerciseId)) + }, + ) + } + + composable( + route = Destinations.SESSION_ROUTE, + arguments = listOf( + navArgument(Destinations.ARG_SESSION_ID) { type = NavType.LongType; defaultValue = 0L }, + navArgument(Destinations.ARG_ROUTINE_ID) { type = NavType.LongType; defaultValue = 0L }, + ), + ) { + SessionScreen(onBack = { navController.popBackStack() }) + } + + composable( + route = Destinations.PROGRESS_ROUTE, + arguments = listOf( + navArgument(Destinations.ARG_EXERCISE_ID) { type = NavType.LongType; defaultValue = 0L }, + ), + ) { + ProgressScreen(onBack = { navController.popBackStack() }) + } + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/progress/LoadChart.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/LoadChart.kt new file mode 100644 index 0000000..99fe45e --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/LoadChart.kt @@ -0,0 +1,104 @@ +package com.ironmind.app.ui.progress + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold +import kotlin.math.roundToInt + +/** + * Load-evolution chart: a cyan curve (with glow + area fill) over a warm gold field, with faint + * gridlines. Tapping selects the nearest point (reported via [onSelect]) so the caller can show + * its value — making the chart interactive. + */ +@Composable +fun LoadChart( + points: List, + selectedIndex: Int?, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val maxV = points.maxOfOrNull { it.value } ?: 0f + val minV = points.minOfOrNull { it.value } ?: 0f + val range = (maxV - minV).takeIf { it > 0f } ?: 1f + + Canvas( + modifier = modifier.pointerInput(points.size) { + detectTapGestures { offset -> + if (points.isEmpty()) return@detectTapGestures + val idx = if (points.size == 1) { + 0 + } else { + ((offset.x / size.width) * (points.size - 1)).roundToInt() + .coerceIn(0, points.size - 1) + } + onSelect(idx) + } + }, + ) { + val padX = 16.dp.toPx() + val padY = 16.dp.toPx() + val chartW = size.width - padX * 2 + val chartH = size.height - padY * 2 + + // Warm gold field. + drawRoundRect( + brush = Brush.verticalGradient(listOf(Gold.copy(alpha = 0.30f), Gold.copy(alpha = 0.06f))), + cornerRadius = CornerRadius(28f, 28f), + ) + + // Faint gridlines. + val gridColor = Color.White.copy(alpha = 0.08f) + for (i in 0..4) { + val y = padY + chartH * i / 4f + drawLine(gridColor, Offset(padX, y), Offset(size.width - padX, y), strokeWidth = 1f) + } + + if (points.isEmpty()) return@Canvas + + fun x(i: Int): Float = + padX + if (points.size == 1) chartW / 2f else chartW * i / (points.size - 1) + + fun y(v: Float): Float = padY + chartH * (1f - (v - minV) / range) + + // Curve path. + val line = Path() + points.forEachIndexed { i, p -> + val px = x(i); val py = y(p.value) + if (i == 0) line.moveTo(px, py) else line.lineTo(px, py) + } + + // Cyan area fill under the curve. + val area = Path().apply { + addPath(line) + lineTo(x(points.lastIndex), padY + chartH) + lineTo(x(0), padY + chartH) + close() + } + drawPath(area, Brush.verticalGradient(listOf(Cyan.copy(alpha = 0.35f), Cyan.copy(alpha = 0f)))) + + // Glow underlay + crisp cyan line. + drawPath(line, color = Cyan.copy(alpha = 0.25f), style = Stroke(width = 12f, cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawPath(line, color = Cyan, style = Stroke(width = 4f, cap = StrokeCap.Round, join = StrokeJoin.Round)) + + // Points, with the selected one emphasized. + points.forEachIndexed { i, p -> + val center = Offset(x(i), y(p.value)) + val selected = i == selectedIndex + drawCircle(color = Cyan, radius = if (selected) 9f else 5f, center = center) + if (selected) drawCircle(color = Color.White, radius = 4f, center = center) + } + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressScreen.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressScreen.kt new file mode 100644 index 0000000..91cfabb --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressScreen.kt @@ -0,0 +1,151 @@ +package com.ironmind.app.ui.progress + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.ui.components.GlassCard +import com.ironmind.app.ui.components.LabeledValue +import com.ironmind.app.ui.components.SectionTitle +import com.ironmind.app.ui.theme.Black +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold +import com.ironmind.app.ui.theme.TextMuted +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProgressScreen( + onBack: () -> Unit, + viewModel: ProgressViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var selectedIndex by remember { mutableStateOf(null) } + + Scaffold( + containerColor = Black, + topBar = { + TopAppBar( + title = { Text(state.exerciseName.ifEmpty { "Progreso" }, color = Gold, fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver", tint = Cyan) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Black), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + GlassCard { + SectionTitle("Evolución de carga", accent = Cyan) + Spacer(Modifier.height(8.dp)) + + if (state.points.isEmpty()) { + Text("Aún no hay datos suficientes para graficar.", color = TextMuted) + } else { + val shown = selectedIndex?.let { state.points.getOrNull(it) } ?: state.points.last() + Text( + "${shown.label} · ${shown.value.toInt()} kg", + color = Cyan, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(12.dp)) + LoadChart( + points = state.points, + selectedIndex = selectedIndex, + onSelect = { selectedIndex = it }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + ) + Spacer(Modifier.height(12.dp)) + LabeledValue("Mejor marca", "${state.bestWeight.toInt()} kg") + } + } + } + + item { SectionTitle("Historial") } + + if (state.history.isEmpty()) { + item { + GlassCard { Text("Sin registros todavía.", color = TextMuted) } + } + } + + items(state.history, key = { it.id }) { set -> + HistoryRow(set) + } + } + } +} + +@Composable +private fun HistoryRow(set: SetLog) { + GlassCard(contentPadding = PaddingValues(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(formatDate(set.performedAt), color = TextMuted, style = MaterialTheme.typography.labelLarge) + Text( + buildString { + append("${set.weightKg.toInt()} kg × ${set.reps}") + set.rpe?.let { append(" · RPE ${it}") } + }, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + } + if (!set.notes.isNullOrBlank()) { + Text(set.notes, color = TextMuted, style = MaterialTheme.typography.labelLarge) + } + } +} + +private val historyFormatter = DateTimeFormatter.ofPattern("dd MMM yyyy") + +private fun formatDate(millis: Long): String = + historyFormatter.format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault())) diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressUiState.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressUiState.kt new file mode 100644 index 0000000..a6fcc6d --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressUiState.kt @@ -0,0 +1,19 @@ +package com.ironmind.app.ui.progress + +import com.ironmind.app.domain.model.SetLog + +/** One point on the load-evolution curve: the top working weight for a given day. */ +data class LoadPoint( + val timeMillis: Long, + val value: Float, + val label: String, +) + +data class ProgressUiState( + val isLoading: Boolean = true, + val exerciseId: Long = 0, + val exerciseName: String = "", + val points: List = emptyList(), + val history: List = emptyList(), + val bestWeight: Float = 0f, +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressViewModel.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressViewModel.kt new file mode 100644 index 0000000..bc49915 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/progress/ProgressViewModel.kt @@ -0,0 +1,85 @@ +package com.ironmind.app.ui.progress + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ironmind.app.domain.repository.WorkoutRepository +import com.ironmind.app.ui.navigation.Destinations +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class ProgressViewModel @Inject constructor( + private val repository: WorkoutRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val argExerciseId: Long = savedStateHandle[Destinations.ARG_EXERCISE_ID] ?: 0L + private val zone: ZoneId = ZoneId.systemDefault() + private val shortDate = DateTimeFormatter.ofPattern("dd/MM") + + // If no exercise was passed, follow the most recently trained one. + private val resolvedExerciseId: Flow = + if (argExerciseId != 0L) { + flowOf(argExerciseId) + } else { + repository.observeSessionDetails() + .map { sessions -> sessions.firstOrNull()?.sets?.maxByOrNull { it.performedAt }?.exerciseId ?: 0L } + .distinctUntilChanged() + } + + val uiState: StateFlow = resolvedExerciseId.flatMapLatest { id -> + if (id == 0L) { + flowOf(ProgressUiState(isLoading = false)) + } else { + combine( + repository.observeSetLogsForExercise(id), + repository.observeExercises(), + ) { logs, exercises -> + val name = exercises.firstOrNull { it.id == id }?.name ?: "Ejercicio" + val working = logs.filter { !it.isWarmup } + + val points = working + .groupBy { dayStartMillis(it.performedAt) } + .map { (dayStart, daySets) -> + LoadPoint( + timeMillis = dayStart, + value = daySets.maxOf { it.weightKg }.toFloat(), + label = shortDate.format(Instant.ofEpochMilli(dayStart).atZone(zone)), + ) + } + .sortedBy { it.timeMillis } + + ProgressUiState( + isLoading = false, + exerciseId = id, + exerciseName = name, + points = points, + history = logs.sortedByDescending { it.performedAt }, + bestWeight = points.maxOfOrNull { it.value } ?: 0f, + ) + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ProgressUiState(), + ) + + private fun dayStartMillis(millis: Long): Long = + Instant.ofEpochMilli(millis).atZone(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionScreen.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionScreen.kt new file mode 100644 index 0000000..da5df27 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionScreen.kt @@ -0,0 +1,257 @@ +package com.ironmind.app.ui.session + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.ui.components.AccentButton +import com.ironmind.app.ui.components.GlassCard +import com.ironmind.app.ui.components.LabeledValue +import com.ironmind.app.ui.components.SectionTitle +import com.ironmind.app.ui.theme.Black +import com.ironmind.app.ui.theme.Cyan +import com.ironmind.app.ui.theme.Gold +import com.ironmind.app.ui.theme.TextMuted + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionScreen( + onBack: () -> Unit, + viewModel: SessionViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val restRemaining by viewModel.restRemaining.collectAsStateWithLifecycle() + + Scaffold( + containerColor = Black, + topBar = { + TopAppBar( + title = { Text(state.title, color = Gold, fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver", tint = Cyan) + } + }, + actions = { + TextButton(onClick = { viewModel.finishSession(onBack) }) { + Text("Finalizar", color = Cyan, fontWeight = FontWeight.SemiBold) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Black), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + GlassCard { + val totalSets = state.exerciseBlocks.sumOf { it.sets.size } + LabeledValue("Volumen total", "${state.totalVolume.toInt()} kg") + Spacer(Modifier.height(6.dp)) + LabeledValue("Sets registrados", "$totalSets") + } + } + + item { RestTimerCard(restRemaining = restRemaining, onStart = viewModel::startRest, onStop = viewModel::stopRest) } + + item { + AddSetCard( + exercises = state.availableExercises.map { it.id to it.name }, + onAddSet = { exerciseId, weight, reps, notes -> + viewModel.addSet(exerciseId, weight, reps, notes) + }, + ) + } + + item { SectionTitle("Ejercicios") } + + items(state.exerciseBlocks, key = { it.exerciseId }) { block -> + ExerciseBlockCard(block = block, onDeleteSet = viewModel::deleteSet) + } + } + } +} + +@Composable +private fun RestTimerCard(restRemaining: Int, onStart: (Int) -> Unit, onStop: () -> Unit) { + GlassCard { + SectionTitle("Descanso", accent = Cyan) + Spacer(Modifier.height(12.dp)) + Text( + text = formatMmSs(restRemaining), + color = if (restRemaining > 0) Cyan else TextMuted, + style = androidx.compose.material3.MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(60, 90, 120).forEach { seconds -> + OutlinedButton(onClick = { onStart(seconds) }) { Text("${seconds}s", color = Gold) } + } + if (restRemaining > 0) { + TextButton(onClick = onStop) { Text("Detener", color = TextMuted) } + } + } + } +} + +@Composable +private fun AddSetCard( + exercises: List>, + onAddSet: (exerciseId: Long, weightKg: Double, reps: Int, notes: String?) -> Unit, +) { + var selectedId by remember { mutableLongStateOf(0L) } + var expanded by remember { mutableStateOf(false) } + var weight by remember { mutableStateOf("") } + var reps by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + + // Default the selector to the first available exercise once loaded. + LaunchedEffect(exercises) { + if (selectedId == 0L && exercises.isNotEmpty()) selectedId = exercises.first().first + } + val selectedName = exercises.firstOrNull { it.first == selectedId }?.second ?: "Selecciona ejercicio" + + GlassCard { + SectionTitle("Registrar set", accent = Gold) + Spacer(Modifier.height(12.dp)) + + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text(selectedName, color = Cyan) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + exercises.forEach { (id, name) -> + DropdownMenuItem(text = { Text(name) }, onClick = { + selectedId = id + expanded = false + }) + } + } + } + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = weight, + onValueChange = { weight = it }, + label = { Text("Peso (kg)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = reps, + onValueChange = { reps = it }, + label = { Text("Reps") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notas (suplementos, energía…)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + val weightValue = weight.toDoubleOrNull() + val repsValue = reps.toIntOrNull() + AccentButton( + text = "Registrar set", + enabled = selectedId != 0L && weightValue != null && repsValue != null, + onClick = { + if (weightValue != null && repsValue != null) { + onAddSet(selectedId, weightValue, repsValue, notes) + weight = "" + reps = "" + notes = "" + } + }, + ) + } +} + +@Composable +private fun ExerciseBlockCard(block: ExerciseBlockUi, onDeleteSet: (SetLog) -> Unit) { + GlassCard { + Text(block.exerciseName, style = androidx.compose.material3.MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + if (block.sets.isEmpty()) { + Text("Sin sets todavía.", color = TextMuted) + } else { + block.sets.forEach { set -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Set ${set.setNumber}: ${set.weightKg.toInt()} kg × ${set.reps}", + color = Color.White, + ) + TextButton(onClick = { onDeleteSet(set) }) { Text("✕", color = TextMuted) } + } + if (!set.notes.isNullOrBlank()) { + Text(" ${set.notes}", color = TextMuted, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) + } + } + } + } +} + +private fun formatMmSs(totalSeconds: Int): String { + val m = totalSeconds / 60 + val s = totalSeconds % 60 + return "%02d:%02d".format(m, s) +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionUiState.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionUiState.kt new file mode 100644 index 0000000..c6d950d --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionUiState.kt @@ -0,0 +1,22 @@ +package com.ironmind.app.ui.session + +import com.ironmind.app.domain.model.Exercise +import com.ironmind.app.domain.model.SetLog + +/** Sets grouped under one exercise within the active session. */ +data class ExerciseBlockUi( + val exerciseId: Long, + val exerciseName: String, + val sets: List, +) + +data class SessionUiState( + val isLoading: Boolean = true, + val sessionId: Long = 0, + val title: String = "Sesión", + val startedAt: Long = 0, + val isFinished: Boolean = false, + val totalVolume: Double = 0.0, + val exerciseBlocks: List = emptyList(), + val availableExercises: List = emptyList(), +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionViewModel.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionViewModel.kt new file mode 100644 index 0000000..dfca09f --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/session/SessionViewModel.kt @@ -0,0 +1,153 @@ +package com.ironmind.app.ui.session + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ironmind.app.domain.model.SetLog +import com.ironmind.app.domain.model.WorkoutSession +import com.ironmind.app.domain.repository.WorkoutRepository +import com.ironmind.app.ui.navigation.Destinations +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class SessionViewModel @Inject constructor( + private val repository: WorkoutRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val argSessionId: Long = savedStateHandle[Destinations.ARG_SESSION_ID] ?: 0L + private val argRoutineId: Long = savedStateHandle[Destinations.ARG_ROUTINE_ID] ?: 0L + + private val activeSessionId = MutableStateFlow(0L) + + init { + viewModelScope.launch { + activeSessionId.value = if (argSessionId != 0L) { + argSessionId + } else { + repository.startSession( + WorkoutSession( + startedAt = System.currentTimeMillis(), + routineId = argRoutineId.takeIf { it != 0L }, + ), + ) + } + } + } + + private val detailFlow = activeSessionId.flatMapLatest { id -> + if (id == 0L) flowOf(null) else repository.observeSessionDetail(id) + } + + private val routinePlanFlow = + if (argRoutineId != 0L) repository.observeRoutinePlan(argRoutineId) else flowOf(null) + + val uiState: StateFlow = combine( + detailFlow, + repository.observeExercises(), + routinePlanFlow, + ) { detail, exercises, plan -> + val names = exercises.associate { it.id to it.name } + val setsByExercise = (detail?.sets ?: emptyList()).groupBy { it.exerciseId } + + val orderedIds = buildList { + plan?.exercises?.forEach { add(it.id) } + setsByExercise.keys.forEach { if (it !in this) add(it) } + } + + SessionUiState( + isLoading = detail == null, + sessionId = detail?.session?.id ?: 0, + title = plan?.routine?.name ?: detail?.session?.title ?: "Sesión libre", + startedAt = detail?.session?.startedAt ?: 0, + isFinished = detail?.session?.endedAt != null, + totalVolume = detail?.totalVolume ?: 0.0, + exerciseBlocks = orderedIds.map { id -> + ExerciseBlockUi( + exerciseId = id, + exerciseName = names[id] ?: "Ejercicio", + sets = setsByExercise[id]?.sortedBy { it.setNumber } ?: emptyList(), + ) + }, + availableExercises = exercises, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SessionUiState(), + ) + + // ---- Rest timer ----------------------------------------------------------------- + private val _restRemaining = MutableStateFlow(0) + val restRemaining: StateFlow = _restRemaining.asStateFlow() + private var restJob: Job? = null + + fun startRest(seconds: Int) { + restJob?.cancel() + _restRemaining.value = seconds + restJob = viewModelScope.launch { + while (_restRemaining.value > 0) { + delay(1_000) + _restRemaining.value -= 1 + } + } + } + + fun stopRest() { + restJob?.cancel() + _restRemaining.value = 0 + } + + // ---- Set logging ---------------------------------------------------------------- + fun addSet(exerciseId: Long, weightKg: Double, reps: Int, notes: String?, autoRestSeconds: Int? = 90) { + val id = activeSessionId.value + if (id == 0L || exerciseId == 0L) return + val nextSetNumber = + (uiState.value.exerciseBlocks.firstOrNull { it.exerciseId == exerciseId }?.sets?.size ?: 0) + 1 + viewModelScope.launch { + repository.upsertSetLog( + SetLog( + sessionId = id, + exerciseId = exerciseId, + setNumber = nextSetNumber, + weightKg = weightKg, + reps = reps, + notes = notes?.takeIf { it.isNotBlank() }, + ), + ) + } + autoRestSeconds?.let { startRest(it) } + } + + fun deleteSet(set: SetLog) { + viewModelScope.launch { repository.deleteSetLog(set) } + } + + fun finishSession(onDone: () -> Unit) { + val id = activeSessionId.value + if (id == 0L) { + onDone() + return + } + viewModelScope.launch { + repository.getSession(id)?.let { session -> + repository.updateSession(session.copy(endedAt = System.currentTimeMillis())) + } + onDone() + } + } +} diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Shape.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Shape.kt new file mode 100644 index 0000000..f71b534 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Shape.kt @@ -0,0 +1,14 @@ +package com.ironmind.app.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** Soft, rounded shapes — the glass cards read as smooth, immersive panels. */ +val IronMindShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(28.dp), + extraLarge = RoundedCornerShape(36.dp), +) diff --git a/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt index 8d0261c..d5e26ff 100644 --- a/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt @@ -27,6 +27,7 @@ fun IronMindTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = IronMindColorScheme, typography = Typography, + shapes = IronMindShapes, content = content, ) } diff --git a/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt b/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt index 5246ef9..cbcb657 100644 --- a/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt +++ b/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt @@ -127,6 +127,7 @@ private class FakeWorkoutRepository( override fun observeSessions(): Flow> = emptyFlow() override fun observeSessionDetails(): Flow> = emptyFlow() override fun observeSessionDetail(sessionId: Long): Flow = emptyFlow() + override suspend fun getSession(id: Long): WorkoutSession? = null override suspend fun startSession(session: WorkoutSession): Long = 0 override suspend fun updateSession(session: WorkoutSession) = Unit override suspend fun deleteSession(session: WorkoutSession) = Unit diff --git a/IronMind/app/src/test/java/com/ironmind/app/StreakCalculatorTest.kt b/IronMind/app/src/test/java/com/ironmind/app/StreakCalculatorTest.kt new file mode 100644 index 0000000..21163db --- /dev/null +++ b/IronMind/app/src/test/java/com/ironmind/app/StreakCalculatorTest.kt @@ -0,0 +1,45 @@ +package com.ironmind.app + +import com.ironmind.app.domain.util.StreakCalculator +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId + +class StreakCalculatorTest { + + private val zone = ZoneId.of("UTC") + private val today = LocalDate.of(2026, 1, 10) + + private fun day(date: LocalDate): Long = + date.atStartOfDay(zone).toInstant().toEpochMilli() + + @Test + fun emptyHistoryIsZero() { + assertEquals(0, StreakCalculator.currentStreak(emptyList(), today, zone)) + } + + @Test + fun consecutiveDaysEndingTodayCount() { + val millis = listOf(today, today.minusDays(1), today.minusDays(2)).map(::day) + assertEquals(3, StreakCalculator.currentStreak(millis, today, zone)) + } + + @Test + fun countsFromYesterdayWhenTodayNotTrainedYet() { + val millis = listOf(today.minusDays(1), today.minusDays(2)).map(::day) + assertEquals(2, StreakCalculator.currentStreak(millis, today, zone)) + } + + @Test + fun gapBeforeYesterdayResetsToZero() { + val millis = listOf(today.minusDays(3), today.minusDays(4)).map(::day) + assertEquals(0, StreakCalculator.currentStreak(millis, today, zone)) + } + + @Test + fun multipleSessionsSameDayCountOnce() { + val millis = listOf(day(today), day(today) + 3_600_000, day(today.minusDays(1))) + assertEquals(2, StreakCalculator.currentStreak(millis, today, zone)) + } +} diff --git a/IronMind/gradle/libs.versions.toml b/IronMind/gradle/libs.versions.toml index 9ce361a..9c1f9da 100644 --- a/IronMind/gradle/libs.versions.toml +++ b/IronMind/gradle/libs.versions.toml @@ -11,6 +11,7 @@ activityCompose = "1.9.3" # Compose composeBom = "2024.12.01" +navigationCompose = "2.8.5" # Persistence room = "2.6.1" @@ -46,6 +47,7 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } # Room androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }