Conversation
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
left a comment
There was a problem hiding this comment.
Let's dissect what the current solution does:
- It maintains a 1,300-line
routines.json. - It generates approximately 80 key strings in
strings.xmland all translatedvalues-*/strings.xmlfiles. - It syncs them into the Room database on every app update as
WorkoutState.LIBRARY. - It encodes
@template:IDintoworkout.notesin the database. - In the UI (
LibraryScreenViewModel), it:- Reads the database.
- Parses the
@template:IDfromnotes. - Matches it back to the in-memory parsed JSON.
- Uses reflection (
getIdentifier) to find the string resource ID.
- 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.stringkeys in JSON. - Direct deserialization into
title: Stringanddescription: 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, andSetrows in Room?
No. You could simply store a hiddenTemplateIds set in DataStore:
Set<String>Architecture Without Room for the Library
routines.jsondefines the templates, including their categories and exercises.LibraryCatalogis an in-memory list, parsed once from JSON or defined in Kotlin.- The Room database is used only for actual user data:
WorkoutState.ROUTINEWorkoutState.RUNNINGWorkoutState.COMPLETED
- When the user taps Add to my routines, the app creates a
Workoutin Room.
Pros and Cons
Pros
- Deletes approximately 300 lines of code:
- No
RoutineTemplateRepository.updateRoutinesOnAppUpdateSynchronously(). - No version tracking in
UserPreferencesRepositorythroughpastRoutinesVersionCode. - No startup synchronization coroutine in
MainApplication.onCreate(). - No Room insertions.
- No
@template:hack inworkout.notes.
- No
- 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.jsonschema-validation scripts.
- No
- Weblate-friendly:
- Titles and descriptions still live in
strings.xml.
- Titles and descriptions still live in
- Instant load time:
- Nearly 0 ms of loading overhead.
Cons
- Routines are defined in
.ktfiles instead of a.jsonfile. - 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 | ✅ Yes — uses strings.xml |
getIdentifier() |
||
| Alternative 1: Multiple JSON files | Medium | ✅ Yes | ❌ 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
pastRoutinesVersionCodein 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 toR.string.xyz.
If moving to a Kotlin catalog:
- Pass
R.string.xyzdirectly 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> |
There was a problem hiding this comment.
Stick to naming conventions of project's DAOs.
| 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) { |
There was a problem hiding this comment.
Inject dispatcher using DatabaseModule.kt
| applicationScope.launch(Dispatchers.IO) { | |
| applicationScope.launch(ioDispatcher) { |
There was a problem hiding this comment.
This cannot be accepted for two reasons:
- Translations cannot be AI generated as state in contribution guidelines
- Translations can be made only via localization platform (Weblate) See chore(i18n): update Chinese translations order #96
There was a problem hiding this comment.
Same as strings.xml. Languanges can be added only when completition is above 80% and relevant weblate PR is merged, in this case #143
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Unused dep (warning by android studio)
| import kotlinx.coroutines.flow.map |
| val currentVersion = | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode else pInfo.versionCode.toLong() |
There was a problem hiding this comment.
| 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") |
| } | ||
| } | ||
|
|
||
| if (failures > 0) Log.w(TAG, "$failures routine template(s) failed to be inserted") |
| * returning null when the resource does not exist. | ||
| */ | ||
| private fun getStringResourceId(name: String): Int? { | ||
| val id = context.resources.getIdentifier(name, "string", context.packageName) |
There was a problem hiding this comment.
In Android development, using dynamic resource lookup via context.resources.getIdentifier(name, "string", context.packageName) is considered an anti-pattern for several reasons:
- R8 / Resource Shrinking Risk: When
isShrinkResources = trueis enabled inbuild.gradle.kts, R8 cannot statically detect usages of string resources likeR.string.routine_ppl_push_titlewhen referenced dynamically as strings in JSON. R8 may strip those string resources from release builds unless guarded in a res/raw/keep.xml file. - Performance: String-based reflection in Resources performs hashtable/package lookup at runtime, which is significantly slower than integer resource constant access (
R.string.xxx). - Typo Safety: Any typo in
routines.jsonorstrings.xmlwon'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
- R8 Safe: R8 sees static references (
R.string.routine_ppl_push_title) and will never accidentally strip string resources during shrinking/minification. - Instant O(1) Performance: Replaces resource reflection with compiled bytecode tableswitch.
- Compile-time safety: Compiler flags any renamed/deleted
R.stringIDs immediately.
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.
100% made with IA