Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions IronMind/.gitignore
Original file line number Diff line number Diff line change
@@ -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/
119 changes: 119 additions & 0 deletions IronMind/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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.** Package structure, Room entities, relations,
DAO, DI wiring, seed catalog. ✅
- **Stage 2 — On-device AI.** MediaPipe LLM Inference (`LlmInferenceManager`) streaming a
`Flow<String>`, `GetProgressionSuggestionUseCase` (Room history → coach prompt → progressive
overload suggestion), and a `SuggestionState` sealed class (Loading / Success / Error). ✅
- **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)

```
com.ironmind.app
├── core/util # DispatcherProvider, Constants — cross-cutting helpers
├── 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
│ │ ├── relation # RoutineWithExercises, SessionWithSets
│ │ ├── converter # Enum <-> String TypeConverters
│ │ ├── 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, Ai
└── 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
lazily loads the MediaPipe engine on `Dispatchers.IO`, opens a session per request, and exposes
the streaming response as a `Flow<String>` 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).
- **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, prompt builder, suggestion use case)
./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.
105 changes: 105 additions & 0 deletions IronMind/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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)
implementation(libs.androidx.navigation.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 (Google AI Edge / MediaPipe LLM Inference API) — Stage 2.
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)
}
14 changes: 14 additions & 0 deletions IronMind/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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.* <methods>;
}

# 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.** { *; }
104 changes: 104 additions & 0 deletions IronMind/app/src/androidTest/java/com/ironmind/app/WorkoutDaoTest.kt
Original file line number Diff line number Diff line change
@@ -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<Context>()
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))
}
}
Loading