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..6ecedd5 --- /dev/null +++ b/IronMind/README.md @@ -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`, `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` 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. diff --git a/IronMind/app/build.gradle.kts b/IronMind/app/build.gradle.kts new file mode 100644 index 0000000..4969b02 --- /dev/null +++ b/IronMind/app/build.gradle.kts @@ -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) +} 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..c488c8a --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/MainActivity.kt @@ -0,0 +1,32 @@ +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.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.ironmind.app.ui.navigation.IronMindNavHost +import com.ironmind.app.ui.theme.IronMindTheme +import dagger.hilt.android.AndroidEntryPoint + +/** Single-activity host for the whole Compose UI. */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + IronMindTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + IronMindNavHost() + } + } + } + } +} 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/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/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..cb9acfb --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/local/dao/WorkoutDao.kt @@ -0,0 +1,136 @@ +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? + + @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 + + @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..b76118b --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/data/repository/WorkoutRepositoryImpl.kt @@ -0,0 +1,141 @@ +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 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()) + } + + 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 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()) + } + + 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/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/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/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/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/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/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..75089a3 --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/domain/repository/WorkoutRepository.kt @@ -0,0 +1,62 @@ +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 getSession(id: Long): WorkoutSession? + 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? + /** 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/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/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/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 new file mode 100644 index 0000000..d5e26ff --- /dev/null +++ b/IronMind/app/src/main/java/com/ironmind/app/ui/theme/Theme.kt @@ -0,0 +1,33 @@ +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, + shapes = IronMindShapes, + 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/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..cbcb657 --- /dev/null +++ b/IronMind/app/src/test/java/com/ironmind/app/GetProgressionSuggestionUseCaseTest.kt @@ -0,0 +1,138 @@ +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 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 + 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")) + } +} 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/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..9c1f9da --- /dev/null +++ b/IronMind/gradle/libs.versions.toml @@ -0,0 +1,89 @@ +[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" +navigationCompose = "2.8.5" + +# 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" } +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" } +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 0000000..1b33c55 Binary files /dev/null and b/IronMind/gradle/wrapper/gradle-wrapper.jar differ diff --git a/IronMind/gradle/wrapper/gradle-wrapper.properties b/IronMind/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/IronMind/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/IronMind/gradlew b/IronMind/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/IronMind/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright Β© 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», +# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; +# * compound commands having a testable exit status, especially Β«caseΒ»; +# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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")