Skip to content

feat: add routine template library - #162

Draft
moobyfr wants to merge 3 commits into
LibreFitOrg:mainfrom
moobyfr:feat/routine-library
Draft

moobyfr wants to merge 3 commits into
LibreFitOrg:mainfrom
moobyfr:feat/routine-library

Conversation

@moobyfr

@moobyfr moobyfr commented Aug 23, 2026

Copy link
Copy Markdown

Ship 30 ready-to-use routine templates in res/raw/routines.json, grouped into 9 categories (beginner, full body, PPL, strength, hypertrophy, bodyweight, cardio, targeted, mobility). Templates are synced into the workouts table (WorkoutState.LIBRARY) on each app update, sequenced after the dataset refresh; user deletions are preserved across updates.

  • Parse templates with kotlinx.serialization (RoutineTemplate)
  • Localize titles/descriptions/categories via string resources
  • Library screen: expandable cards, estimated duration, add to my routines, long-press to remove from library
  • Add JSON schema + validate_routines_json.py to guard routines.json
  • Document the 30 templates per category in ROUTINES.md

100% made with IA

BLINDAUER EMMANUEL added 3 commits August 23, 2026 21:54
Ship 30 ready-to-use routine templates in res/raw/routines.json, grouped
into 9 categories (beginner, full body, PPL, strength, hypertrophy,
bodyweight, cardio, targeted, mobility). Templates are synced into the
workouts table (WorkoutState.LIBRARY) on each app update, sequenced
after the dataset refresh; user deletions are preserved across updates.

- Parse templates with kotlinx.serialization (RoutineTemplate)
- Localize titles/descriptions/categories via string resources
- Library screen: expandable cards, estimated duration, add to my
  routines, long-press to remove from library
- Add JSON schema + validate_routines_json.py to guard routines.json
- Document the 30 templates per category in ROUTINES.md
"fr" was missing from localeFilters, so all values-fr resources were
stripped from the APK regardless of the device settings. Also add French
to the Language enum (the language_french strings already existed) and
re-resolve the library categories when the in-app language changes, as
the ViewModel outlives activity recreations.
CONTRIBUTING.md instructs contributors to create a Python virtual
environment (.venv) at the repository root to run the JSON validation
scripts, and validate_exercises_json.py generates ordered_exercises.json:
both were not ignored. Also dedupe local.properties and group entries
by purpose.

@IamDg IamDg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's dissect what the current solution does:

  1. It maintains a 1,300-line routines.json.
  2. It generates approximately 80 key strings in strings.xml and all translated values-*/strings.xml files.
  3. It syncs them into the Room database on every app update as WorkoutState.LIBRARY.
  4. It encodes @template:ID into workout.notes in the database.
  5. In the UI (LibraryScreenViewModel), it:
    • Reads the database.
    • Parses the @template:ID from notes.
    • Matches it back to the in-memory parsed JSON.
    • Uses reflection (getIdentifier) to find the string resource ID.
  6. When a template is added to routines, it copies the data from JSON/Room into a new WorkoutState.ROUTINE.

That is a lot of indirection for 30 static workout templates!

Alternative 1: Multiple Localized JSON Files in res/raw-<locale>/

Android natively supports locale qualifiers for raw resources:

res/raw/routines.json       # Default / English
res/raw-fr/routines.json    # French
res/raw-es/routines.json    # Spanish

For example, the default file might contain:

{
  "title": "Push Day (PPL)",
  "description": "..."
}

How It Works

context.resources.openRawResource(R.raw.routines)

This automatically opens the version matching the active locale.

Pros and Cons

Pros

  • Zero reflection.
  • No getIdentifier.
  • No R.string keys in JSON.
  • Direct deserialization into title: String and description: String.

Cons

Major blocker for open source and Weblate:

  • Translation tools such as Weblate, Crowdin, and Transifex work natively with strings.xml.
  • Translating a 1,300-line JSON file means community translators would have to edit raw JSON.
  • Any structure change—such as adding an exercise or changing a rest time—must be manually synchronized across 10 different JSON files.
  • If a French JSON file is out of date, the app could crash or display mismatched exercises.

Verdict

Not recommended. Maintaining duplicate JSON files across 10 or more languages is fragile and breaks standard Android localization workflows.

Alternative 2: Questioning the Premises — Do Library Templates Need to Live in Room at All?

Look closely at why Room is being used for templates.

The PR puts library templates into Room (state = WorkoutState.LIBRARY) solely so users can delete a library template and have that deletion persist across restarts.

Consider the following:

  • Is deleting a read-only library template a critical feature for a workout app?
  • Most workout apps—such as Strong, Hevy, Boostcamp, and Caliber—treat built-in templates as read-only catalogs. Users do not delete catalog templates; they browse the library and choose Save as Routine or Start Workout.
  • If users need to hide templates, is it necessary to store full Workout, Exercise, and Set rows in Room?

No. You could simply store a hiddenTemplateIds set in DataStore:

Set<String>

Architecture Without Room for the Library

  1. routines.json defines the templates, including their categories and exercises.
  2. LibraryCatalog is an in-memory list, parsed once from JSON or defined in Kotlin.
  3. The Room database is used only for actual user data:
    • WorkoutState.ROUTINE
    • WorkoutState.RUNNING
    • WorkoutState.COMPLETED
  4. When the user taps Add to my routines, the app creates a Workout in Room.

Pros and Cons

Pros

  • Deletes approximately 300 lines of code:
    • No RoutineTemplateRepository.updateRoutinesOnAppUpdateSynchronously().
    • No version tracking in UserPreferencesRepository through pastRoutinesVersionCode.
    • No startup synchronization coroutine in MainApplication.onCreate().
    • No Room insertions.
    • No @template: hack in workout.notes.
  • Zero startup penalty:
    • No database queries or JSON decoding during app launch.
  • Zero data-corruption or stale-data risk:
    • When an app update changes a template's exercises, the library reflects the update immediately because the template is not cached in Room.

Verdict

A major win for simplicity and idiomatic Android architecture.

Alternative 3: Code-First Kotlin Catalog

What if the 30 routine templates were written directly in Kotlin instead of a 1,300-line JSON file?

Example

data class RoutineTemplate(
    val id: String,
    @StringRes val titleRes: Int,
    @StringRes val descRes: Int,
    val category: RoutineCategory,
    val exercises: List<RoutineTemplateExercise>
)

object RoutineCatalog {
    val all: List<RoutineTemplate> = listOf(
        RoutineTemplate(
            id = "PPL_Push",
            titleRes = R.string.routine_ppl_push_title,
            descRes = R.string.routine_ppl_push_desc,
            category = RoutineCategory.PPL,
            exercises = listOf(
                RoutineTemplateExercise(
                    "bench_press",
                    sets = 3,
                    reps = 8,
                    restTime = 90
                ),
                // ...
            )
        ),
        // ...
    )
}

Pros and Cons

Pros

  • 100% type-safe: The compiler verifies all string resource IDs (R.string.xxx), exercise references, and categories.
  • Zero reflection: No getIdentifier().
  • Zero JSON parsing:
    • No kotlinx.serialization.
    • No runtime file I/O.
    • No routines.json schema-validation scripts.
  • Weblate-friendly:
    • Titles and descriptions still live in strings.xml.
  • Instant load time:
    • Nearly 0 ms of loading overhead.

Cons

  • Routines are defined in .kt files instead of a .json file.
  • Some developers may prefer JSON for non-developer contributors.
  • However, JSON already requires a custom Python validator script.

Verdict

The most idiomatic Android solution.

Comparison Summary

Approach Code Complexity R8 / Shrink Safe? Startup Impact Weblate / Translation Friendly? Reflection Needed?
Current PR: Room + JSON + Reflection High — approximately 600+ lines, database synchronization, and notes prefix ⚠️ Risky unless carefully maintained ⚠️ Runs on startup ✅ Yes — uses strings.xml ⚠️ Yes — getIdentifier()
Alternative 1: Multiple JSON files Medium ✅ Yes ⚠️ File I/O ❌ No — translators must edit JSON ❌ No
Alternative 2: Current JSON + no Room synchronization + static resource map Low ✅ Yes ✅ Lazy-loaded on the library screen ✅ Yes — uses strings.xml ❌ No
Alternative 3: Code-first Kotlin catalog + no Room synchronization Lowest ✅ Yes ✅ Zero I/O ✅ Yes — uses strings.xml ❌ No

What Should You Sacrifice, and What Is the Best Pragmatic Path?

If you want to make this PR much simpler, more robust, and more idiomatic, the recommended feedback is as follows.

1. Sacrifice Room Persistence for Library Templates

Stop treating the library catalog as database rows.

Templates are static assets shipped with the app. Keeping a copy in the workouts table, with @template:PPL_Push smuggled into workout.notes, is an anti-pattern.

Simplicity Gains

  • Eliminates updateRoutinesOnAppUpdateSynchronously().
  • Eliminates pastRoutinesVersionCode in DataStore.
  • Eliminates startup synchronization in MainApplication.
  • Eliminates complex Room DAO queries.

If users truly need to hide templates, store the following in DataStore:

hiddenTemplateIds: Set<String>

2. Connect Strings Using Compile-Time Constants, Not Reflection

If keeping routines.json:

  • Use a simple when (key) mapping object to map string keys to R.string.xyz.

If moving to a Kotlin catalog:

  • Pass R.string.xyz directly in the definitions.

This eliminates reflection, guarantees resource-shrinking safety with R8, removes unnecessary database bloat, and cuts the PR's maintenance burden approximately in half.

*/
@Transaction
@Query("SELECT * FROM workouts WHERE state = :state ORDER BY created")
suspend fun getWorkoutsWithExercisesAndSetsListByState(state: WorkoutState): List<WorkoutWithExercisesAndSets>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stick to naming conventions of project's DAOs.

Suggested change
suspend fun getWorkoutsWithExercisesAndSetsListByState(state: WorkoutState): List<WorkoutWithExercisesAndSets>
suspend fun getWorkoutsWithExercisesAndSetsListByStateOnce(state: WorkoutState): List<WorkoutWithExercisesAndSets>

datasetRepository.updateDatasetOnAppUpdate()
// Update dataset and routine templates on each app update. They are sequenced because
// routine templates reference exercises from the dataset by id.
applicationScope.launch(Dispatchers.IO) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inject dispatcher using DatabaseModule.kt

Suggested change
applicationScope.launch(Dispatchers.IO) {
applicationScope.launch(ioDispatcher) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cannot be accepted for two reasons:

  1. Translations cannot be AI generated as state in contribution guidelines
  2. Translations can be made only via localization platform (Weblate) See chore(i18n): update Chinese translations order #96

Comment thread app/build.gradle.kts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as strings.xml. Languanges can be added only when completition is above 80% and relevant weblate PR is merged, in this case #143

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as strings.xml. Languanges can be added only when completition is above 80% and relevant weblate PR is merged, in this case #143

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused dep (warning by android studio)

Suggested change
import kotlinx.coroutines.flow.map

Comment on lines +63 to +64
val currentVersion =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode else pInfo.versionCode.toLong()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val currentVersion =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode else pInfo.versionCode.toLong()
val currentVersion = PackageInfoCompat.getLongVersionCode(pInfo)

.mapNotNull { RoutineTemplates.templateIdFromNotes(it.workout.notes) }
.toSet()

Log.i(TAG, "Sync: ${templates.size} templates parsed, ${existingTemplateIds.size} already present")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there log here?

}
}

if (failures > 0) Log.w(TAG, "$failures routine template(s) failed to be inserted")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why logging?

* returning null when the resource does not exist.
*/
private fun getStringResourceId(name: String): Int? {
val id = context.resources.getIdentifier(name, "string", context.packageName)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Android development, using dynamic resource lookup via context.resources.getIdentifier(name, "string", context.packageName) is considered an anti-pattern for several reasons:

  1. R8 / Resource Shrinking Risk: When isShrinkResources = true is enabled in build.gradle.kts, R8 cannot statically detect usages of string resources like R.string.routine_ppl_push_title when referenced dynamically as strings in JSON. R8 may strip those string resources from release builds unless guarded in a res/raw/keep.xml file.
  2. Performance: String-based reflection in Resources performs hashtable/package lookup at runtime, which is significantly slower than integer resource constant access (R.string.xxx).
  3. Typo Safety: Any typo in routines.json or strings.xml won't fail at compile time or in build checks.

Instead of passing raw resource key strings ("routine_ppl_push_title"), map string keys or template IDs directly to @StringRes Int constants in a Kotlin helper object.

Implementation:

Create a lookup object/extension:

package org.librefit.util

import androidx.annotation.StringRes
import org.librefit.R

object RoutineResourceMapper {

    @StringRes
    fun getStringResId(key: String): Int? = when (key) {
        // Categories
        "routine_category_beginner" -> R.string.routine_category_beginner
        "routine_category_full_body" -> R.string.routine_category_full_body
        "routine_category_ppl" -> R.string.routine_category_ppl
        "routine_category_strength" -> R.string.routine_category_strength
        "routine_category_hypertrophy" -> R.string.routine_category_hypertrophy
        "routine_category_bodyweight" -> R.string.routine_category_bodyweight
        "routine_category_cardio" -> R.string.routine_category_cardio
        "routine_category_targeted" -> R.string.routine_category_targeted
        "routine_category_mobility" -> R.string.routine_category_mobility

        // Titles & Descriptions
        "routine_ppl_push_title" -> R.string.routine_ppl_push_title
        "routine_ppl_push_desc" -> R.string.routine_ppl_push_desc
        "routine_ppl_pull_title" -> R.string.routine_ppl_pull_title
        "routine_ppl_pull_desc" -> R.string.routine_ppl_pull_desc
        // ... map remaining keys ...
        
        else -> null
    }
}

Advantages

  1. R8 Safe: R8 sees static references (R.string.routine_ppl_push_title) and will never accidentally strip string resources during shrinking/minification.
  2. Instant O(1) Performance: Replaces resource reflection with compiled bytecode tableswitch.
  3. Compile-time safety: Compiler flags any renamed/deleted R.string IDs immediately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants