diff --git a/docs/12_week_plan_example.json b/docs/12_week_plan_example.json new file mode 100644 index 0000000..46888de --- /dev/null +++ b/docs/12_week_plan_example.json @@ -0,0 +1,684 @@ +{ + "name": "12-Week Aesthetics + Endurance", + "description": "A 12-week hypertrophy program targeting aesthetics and endurance. Shoulders, arms, and back prioritised. Progressive overload through 3 phases: Foundation, Intensify, and Peak. Includes knee rehab protocol and programmed deloads at weeks 5 and 9.", + "durationWeeks": 12, + "trainingDaysPerWeek": 5, + "author": "RepForge", + "isImported": true, + "phases": [ + { + "id": "phase_1", + "name": "Foundation", + "description": "Build the habit, learn every movement with perfect form. Volume is moderate. Focus on mind-muscle connection. Every set should be felt, not just done.", + "startWeek": 1, + "endWeek": 4, + "defaultSetsDisplay": "3×10", + "defaultRestSeconds": 75, + "rpeTarget": 7, + "isDeloadWeek": false + }, + { + "id": "phase_2", + "name": "Intensify", + "description": "Add a 4th set across all major lifts. Introduce supersets for arms. Rest drops to 60s for isolation work. Visible changes begin — biceps and shoulders get hit directly twice a week.", + "startWeek": 5, + "endWeek": 8, + "defaultSetsDisplay": "4×10", + "defaultRestSeconds": 60, + "rpeTarget": 8, + "isDeloadWeek": false + }, + { + "id": "phase_3", + "name": "Peak", + "description": "Higher reps (12–15), shorter rest (45–60s), tri-sets for arms and shoulders. Maximises muscle endurance and the pump aesthetic — dense, defined muscles.", + "startWeek": 9, + "endWeek": 12, + "defaultSetsDisplay": "4×12", + "defaultRestSeconds": 45, + "rpeTarget": 9, + "isDeloadWeek": false + } + ], + "weeklySchedule": [ + { + "id": "day_mon", + "dayOfWeek": "monday", + "dayType": "push", + "name": "PUSH — Chest + Triceps + Front Delt", + "description": "Primary strength lift day. Start session when fresh.", + "estimatedDurationMinutes": 55, + "isRestDay": false, + "exercises": [ + { + "id": "ex_ibp", + "exerciseId": "incline_bench_press", + "exerciseName": "Incline Bench Press", + "orderIndex": 0, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 40 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 8, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 42.5 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 45 } + ], + "tempo": "3-1-1", + "restSeconds": 90, + "notes": "3s down, 1s pause at bottom, 1s up. Break your 40kg plateau here." + }, + { + "id": "ex_fbp", + "exerciseId": "flat_bench_press", + "exerciseName": "Flat Bench Press", + "orderIndex": 1, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 35 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 37.5 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 40 } + ], + "tempo": "2-0-1", + "restSeconds": 75, + "notes": "Start 5kg lighter than incline. Chest width comes from flat press." + }, + { + "id": "ex_pec", + "exerciseId": "pec_deck", + "exerciseName": "Pec Deck / Cable Fly", + "orderIndex": 2, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Squeeze hard at peak contraction. Hold 1s. Builds chest definition." + }, + { + "id": "ex_ohte", + "exerciseId": "overhead_tricep_extension", + "exerciseName": "Overhead Tricep Extension (Cable)", + "orderIndex": 3, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 30 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 32.5 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 35 } + ], + "tempo": "3-0-1", + "restSeconds": 60, + "notes": "Long head of tricep — biggest part. Elbows stay still, only forearm moves." + }, + { + "id": "ex_tpd_ss", + "exerciseId": "tricep_pushdown", + "exerciseName": "Tricep Pushdown (rope)", + "orderIndex": 4, + "supersetGroup": "A", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false } + ], + "tempo": "2-1-1", + "restSeconds": 0, + "notes": "SUPERSET with Lateral Raise (P2/P3). Flare elbows out at bottom." + }, + { + "id": "ex_lr_ss", + "exerciseId": "lateral_raise", + "exerciseName": "Lateral Raise (standing DB)", + "orderIndex": 5, + "supersetGroup": "B", + "setSchemes": [ + { "phaseId": "phase_2", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 10 }, + { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 11.25 } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Added in Phase 2. Slight forward lean, lead with elbows not hands." + } + ] + }, + { + "id": "day_tue", + "dayOfWeek": "tuesday", + "dayType": "rest", + "name": "REST + REHAB", + "description": "Wall Sits · Tibialis Raises · Clamshells · TKE", + "estimatedDurationMinutes": 20, + "isRestDay": true, + "exercises": [] + }, + { + "id": "day_wed", + "dayOfWeek": "wednesday", + "dayType": "core", + "name": "CORE — Anti-rotation + Stability", + "description": "Better core = better on every other lift. Log this!", + "estimatedDurationMinutes": 32, + "isRestDay": false, + "exercises": [ + { + "id": "ex_plank", + "exerciseId": "plank", + "exerciseName": "Plank", + "orderIndex": 0, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 30, "maxReps": 45, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 45, "maxReps": 60, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 3, "minReps": 60, "maxReps": 60, "isAmrap": false } + ], + "tempo": "controlled", + "restSeconds": 45, + "notes": "Straight line head to heel. Track time improvements week to week." + }, + { + "id": "ex_deadbug", + "exerciseId": "dead_bug", + "exerciseName": "Dead Bug", + "orderIndex": 1, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false } + ], + "tempo": "3-1-3", + "restSeconds": 45, + "notes": "Lower back pressed flat to floor. Opposite arm + leg extend slowly." + }, + { + "id": "ex_pallof", + "exerciseId": "pallof_press", + "exerciseName": "Pallof Press (cable)", + "orderIndex": 2, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false } + ], + "tempo": "2-2-2", + "restSeconds": 45, + "notes": "Anti-rotation. Stand sideways to cable, press out and hold." + }, + { + "id": "ex_cablecrunch", + "exerciseId": "cable_crunch", + "exerciseName": "Cable Crunch", + "orderIndex": 3, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false } + ], + "tempo": "2-1-2", + "restSeconds": 45, + "notes": "Kneel facing cable, rope behind head, crunch down. Weighted ab work." + } + ] + }, + { + "id": "day_thu", + "dayOfWeek": "thursday", + "dayType": "pull", + "name": "PULL — Back + Biceps + Rear Delt", + "description": "This day fixes your arm visibility problem directly.", + "estimatedDurationMinutes": 58, + "isRestDay": false, + "exercises": [ + { + "id": "ex_row", + "exerciseId": "seated_cable_row", + "exerciseName": "Seated Cable Row", + "orderIndex": 0, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 85 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 90 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 92.5 } + ], + "tempo": "2-1-2", + "restSeconds": 90, + "notes": "Your strongest lift (85kg). Row to lower chest, squeeze scapula." + }, + { + "id": "ex_lpd", + "exerciseId": "lat_pulldown", + "exerciseName": "Lat Pulldown (wide grip)", + "orderIndex": 1, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 55 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 57.5 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 60 } + ], + "tempo": "2-1-2", + "restSeconds": 75, + "notes": "Lean back slightly, pull to upper chest, full stretch at top." + }, + { + "id": "ex_pullup", + "exerciseId": "pull_ups", + "exerciseName": "Pull-ups / Assisted Pull-ups", + "orderIndex": 2, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 1, "maxReps": 20, "isAmrap": true }, + { "phaseId": "phase_2", "sets": 3, "minReps": 1, "maxReps": 20, "isAmrap": true }, + { "phaseId": "phase_3", "sets": 3, "minReps": 1, "maxReps": 20, "isAmrap": true } + ], + "tempo": "2-0-1", + "restSeconds": 90, + "notes": "Track reps week to week. Target +3 reps vs Week 1 by end." + }, + { + "id": "ex_rdf", + "exerciseId": "rear_delt_fly", + "exerciseName": "Rear Delt Fly", + "orderIndex": 3, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 35 }, + { "phaseId": "phase_2", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 37.5 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 40 } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Makes shoulders look 3D. Lead with elbows, squeeze at top." + }, + { + "id": "ex_preacher", + "exerciseId": "preacher_curl", + "exerciseName": "Preacher Curl (EZ bar)", + "orderIndex": 4, + "supersetGroup": "A", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 15 }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 16 }, + { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 17.5 } + ], + "tempo": "3-1-1", + "restSeconds": 0, + "notes": "SUPERSET with Hammer Curl. 3s eccentric (down) = key to bicep growth." + }, + { + "id": "ex_hammer", + "exerciseId": "hammer_curl", + "exerciseName": "Hammer Curl (DB, alternating)", + "orderIndex": 5, + "supersetGroup": "B", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 10 }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 11.25 }, + { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 12.5 } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Brachialis = arm thickness. No swinging — strict form only." + } + ] + }, + { + "id": "day_fri", + "dayOfWeek": "friday", + "dayType": "shoulders", + "name": "SHOULDERS + ARMS — Dedicated Day", + "description": "THE session that changes how you look in a t-shirt.", + "estimatedDurationMinutes": 60, + "isRestDay": false, + "exercises": [ + { + "id": "ex_dbsp", + "exerciseId": "db_shoulder_press", + "exerciseName": "DB Shoulder Press (seated)", + "orderIndex": 0, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 12 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 14 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 16 } + ], + "tempo": "2-1-1", + "restSeconds": 90, + "notes": "Elbows at 45°, don't flare out wide. Primary overhead press." + }, + { + "id": "ex_lrseated", + "exerciseId": "lateral_raise_seated", + "exerciseName": "Lateral Raise (seated DB)", + "orderIndex": 1, + "supersetGroup": "A", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 8 }, + { "phaseId": "phase_2", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 10 }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 11.25 } + ], + "tempo": "2-1-3", + "restSeconds": 0, + "notes": "SUPERSET with Rear Delt Fly. 3s SLOW on the way down — eccentric is where lateral head grows." + }, + { + "id": "ex_rdfly", + "exerciseId": "rear_delt_fly_cable", + "exerciseName": "Rear Delt Fly (cable, bent-over)", + "orderIndex": 2, + "supersetGroup": "B", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Shoulders look 3D from every angle. Hit twice a week (also Thu)." + }, + { + "id": "ex_uprow", + "exerciseId": "upright_row", + "exerciseName": "Upright Row (cable)", + "orderIndex": 3, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Keep bar/cable close to body, elbows above wrists. Don't go heavy." + }, + { + "id": "ex_ezcurl", + "exerciseId": "ez_bar_curl", + "exerciseName": "EZ Bar Curl", + "orderIndex": 4, + "supersetGroup": "A", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 10, "maxReps": 10, "isAmrap": false, "suggestedWeightKg": 15 }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 17.5 }, + { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 20 } + ], + "tempo": "3-1-1", + "restSeconds": 0, + "notes": "SUPERSET with Tricep Pushdown. Arms superset = maximum pump in 6 min." + }, + { + "id": "ex_tpdfri", + "exerciseId": "tricep_pushdown_straight", + "exerciseName": "Tricep Pushdown (straight bar)", + "orderIndex": 5, + "supersetGroup": "B", + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 35 }, + { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false, "suggestedWeightKg": 37.5 }, + { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 40 } + ], + "tempo": "2-1-1", + "restSeconds": 45, + "notes": "Keep rest to 45s for metabolic stress. Builds visible arms." + } + ] + }, + { + "id": "day_sat", + "dayOfWeek": "saturday", + "dayType": "legs", + "name": "LEGS + KNEE REHAB", + "description": "Modified for knee rehab — no squats, no lunges. Progressive and safe.", + "estimatedDurationMinutes": 55, + "isRestDay": false, + "exercises": [ + { + "id": "ex_legpress", + "exerciseId": "leg_press", + "exerciseName": "Leg Press (high foot placement)", + "orderIndex": 0, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false } + ], + "tempo": "3-1-1", + "restSeconds": 90, + "notes": "High foot = glutes/hamstrings dominant, less knee stress. Don't lock out." + }, + { + "id": "ex_legcurl", + "exerciseId": "leg_curl", + "exerciseName": "Leg Curl (machine)", + "orderIndex": 1, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 4, "minReps": 12, "maxReps": 12, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 4, "minReps": 15, "maxReps": 15, "isAmrap": false } + ], + "tempo": "3-1-1", + "restSeconds": 75, + "notes": "Slow eccentric (3s down). Helps knee stability long-term." + }, + { + "id": "ex_legext", + "exerciseId": "leg_extension", + "exerciseName": "Leg Extension (light, rehab)", + "orderIndex": 2, + "supersetGroup": null, + "setSchemes": [ + { "phaseId": "phase_1", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_2", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false }, + { "phaseId": "phase_3", "sets": 3, "minReps": 20, "maxReps": 20, "isAmrap": false } + ], + "tempo": "2-1-2", + "restSeconds": 60, + "notes": "Rehab stimulus, not strength. Pain = stop immediately." + } + ] + }, + { + "id": "day_sun", + "dayOfWeek": "sunday", + "dayType": "rest", + "name": "REST", + "description": "Walk / stretch / full recovery.", + "estimatedDurationMinutes": 0, + "isRestDay": true, + "exercises": [] + } + ], + "milestones": [ + { + "id": "ms_w2", + "weekNumber": 2, + "title": "Foundation Locked", + "description": "You should be hitting every exercise with clean form. Focus especially on feeling your biceps, not just moving the weight.", + "phaseId": "phase_1", + "targets": [ + { "description": "All 5 days completed" }, + { "description": "Plank ≥ 30s" }, + { "description": "Knee rehab done Tue + Sat" }, + { "description": "Bicep curl with full 3s eccentric" } + ] + }, + { + "id": "ms_w4", + "weekNumber": 4, + "title": "First Strength Benchmarks", + "description": "By week 4 you should have baseline numbers across every lift. These will be your reference point for all future progression.", + "phaseId": "phase_1", + "targets": [ + { "description": "Incline Bench → 42.5kg × 10", "exerciseId": "incline_bench_press", "targetWeightKg": 42.5, "targetReps": 10 }, + { "description": "Cable Row → 87.5kg × 10", "exerciseId": "seated_cable_row", "targetWeightKg": 87.5, "targetReps": 10 }, + { "description": "Lat Pulldown → 57.5kg × 10", "exerciseId": "lat_pulldown", "targetWeightKg": 57.5, "targetReps": 10 }, + { "description": "Preacher Curl → 16kg × 12", "exerciseId": "preacher_curl", "targetWeightKg": 16, "targetReps": 12 }, + { "description": "Plank → 45s" }, + { "description": "Wall Sit → 45s" } + ] + }, + { + "id": "ms_w6", + "weekNumber": 6, + "title": "Visible Changes Begin", + "description": "Shoulders should look noticeably wider than week 1. Arms should feel different when you flex.", + "phaseId": "phase_2", + "targets": [ + { "description": "Lat Pulldown → 60kg × 10", "exerciseId": "lat_pulldown", "targetWeightKg": 60, "targetReps": 10 }, + { "description": "Hammer Curl → 11.25kg × 12", "exerciseId": "hammer_curl", "targetWeightKg": 11.25, "targetReps": 12 }, + { "description": "Lateral Raise → 11.25kg × 12", "exerciseId": "lateral_raise", "targetWeightKg": 11.25, "targetReps": 12 }, + { "description": "Supersets introduced on Thu + Fri" }, + { "description": "TKE — zero pain with 3 sets" } + ] + }, + { + "id": "ms_w8", + "weekNumber": 8, + "title": "Major Strength Jump", + "description": "Your arms and shoulders should now be visibly developed. 8 weeks of consistent training translates to meaningful weight increases.", + "phaseId": "phase_2", + "targets": [ + { "description": "Incline Bench → 45kg × 10", "exerciseId": "incline_bench_press", "targetWeightKg": 45, "targetReps": 10 }, + { "description": "Cable Row → 92.5kg × 10", "exerciseId": "seated_cable_row", "targetWeightKg": 92.5, "targetReps": 10 }, + { "description": "Preacher Curl → 17.5kg × 12", "exerciseId": "preacher_curl", "targetWeightKg": 17.5, "targetReps": 12 }, + { "description": "EZ Bar Curl → 20kg × 12", "exerciseId": "ez_bar_curl", "targetWeightKg": 20, "targetReps": 12 }, + { "description": "Shoulder Press → 14–16kg × 10" }, + { "description": "Plank → 60s" }, + { "description": "Pull-ups → +2 reps vs Week 1" } + ] + }, + { + "id": "ms_w10", + "weekNumber": 10, + "title": "Endurance + Definition Peak", + "description": "Muscles will appear fuller even on rest days. You'll need more water and food to support this phase.", + "phaseId": "phase_3", + "targets": [ + { "description": "All tri-sets completed on Fri" }, + { "description": "45s rest maintained on arm isolation" }, + { "description": "Leg press → 10kg more than W1" } + ] + }, + { + "id": "ms_w12", + "weekNumber": 12, + "title": "12-Week Transformation Complete", + "description": "Wider shoulders, defined arms, better posture from back work, functional core. People will notice.", + "phaseId": "phase_3", + "targets": [ + { "description": "Incline Bench → 47.5–50kg × 10", "exerciseId": "incline_bench_press", "targetWeightKg": 50, "targetReps": 10 }, + { "description": "Cable Row → 95kg × 10", "exerciseId": "seated_cable_row", "targetWeightKg": 95, "targetReps": 10 }, + { "description": "Preacher Curl → 18–20kg × 12", "exerciseId": "preacher_curl", "targetWeightKg": 20, "targetReps": 12 }, + { "description": "EZ Bar Curl → 22.5kg × 12", "exerciseId": "ez_bar_curl", "targetWeightKg": 22.5, "targetReps": 12 }, + { "description": "Shoulder Press → 17.5–20kg × 10" }, + { "description": "Lateral Raise → 12.5kg × 15", "exerciseId": "lateral_raise", "targetWeightKg": 12.5, "targetReps": 15 }, + { "description": "Pull-ups → +3 reps vs Week 1" }, + { "description": "Plank → 75s" }, + { "description": "Knee pain-free on daily activities" } + ] + } + ], + "progressionRules": [ + { + "exerciseId": "incline_bench_press", + "exerciseName": "Incline Bench Press", + "liftType": "compound", + "currentWeightKg": 40, + "incrementKg": 2.5, + "weekTargets": [ + { "weekNumber": 4, "targetWeightKg": 42.5, "targetReps": 10 }, + { "weekNumber": 8, "targetWeightKg": 45, "targetReps": 10 }, + { "weekNumber": 12, "targetWeightKg": 50, "targetReps": 10 } + ], + "keyNote": "Repeat same weight next session if you fail mid-set" + }, + { + "exerciseId": "seated_cable_row", + "exerciseName": "Seated Cable Row", + "liftType": "compound", + "currentWeightKg": 85, + "incrementKg": 2.5, + "weekTargets": [ + { "weekNumber": 4, "targetWeightKg": 87.5, "targetReps": 10 }, + { "weekNumber": 8, "targetWeightKg": 92.5, "targetReps": 10 }, + { "weekNumber": 12, "targetWeightKg": 95, "targetReps": 10 } + ], + "keyNote": null + }, + { + "exerciseId": "preacher_curl", + "exerciseName": "Preacher Curl", + "liftType": "isolation", + "currentWeightKg": 15, + "incrementKg": 1.25, + "weekTargets": [ + { "weekNumber": 4, "targetWeightKg": 16, "targetReps": 12 }, + { "weekNumber": 8, "targetWeightKg": 17.5, "targetReps": 12 }, + { "weekNumber": 12, "targetWeightKg": 20, "targetReps": 12 } + ], + "keyNote": "3s eccentric every rep — non-negotiable" + }, + { + "exerciseId": "hammer_curl", + "exerciseName": "Hammer Curl", + "liftType": "isolation", + "currentWeightKg": 10, + "incrementKg": 1.25, + "weekTargets": [ + { "weekNumber": 4, "targetWeightKg": 11.25, "targetReps": 12 }, + { "weekNumber": 8, "targetWeightKg": 12.5, "targetReps": 12 }, + { "weekNumber": 12, "targetWeightKg": 15, "targetReps": 12 } + ], + "keyNote": null + }, + { + "exerciseId": "lateral_raise", + "exerciseName": "Lateral Raise", + "liftType": "isolation", + "currentWeightKg": 10, + "incrementKg": 1.25, + "weekTargets": [ + { "weekNumber": 4, "targetWeightKg": 10, "targetReps": 15, "notes": "Reps first before adding weight" }, + { "weekNumber": 8, "targetWeightKg": 11.25, "targetReps": 12 }, + { "weekNumber": 12, "targetWeightKg": 12.5, "targetReps": 15 } + ], + "keyNote": "3s slow eccentric on the way down" + }, + { + "exerciseId": "lat_pulldown", + "exerciseName": "Lat Pulldown", + "liftType": "compound", + "currentWeightKg": 55, + "incrementKg": 2.5, + "weekTargets": [ + { "weekNumber": 4, "targetWeightKg": 57.5, "targetReps": 10 }, + { "weekNumber": 6, "targetWeightKg": 60, "targetReps": 10 }, + { "weekNumber": 12, "targetWeightKg": 62.5, "targetReps": 10 } + ], + "keyNote": null + } + ], + "deloadWeeks": [ + { + "weekNumber": 5, + "weightReductionPercent": 0.12, + "maxSets": 2, + "notes": "Start of Phase 2 — joints and tendons need recovery" + }, + { + "weekNumber": 9, + "weightReductionPercent": 0.12, + "maxSets": 2, + "notes": "Start of Phase 3 — prevents overuse injury, come back stronger" + } + ], + "metadata": { + "repRange": "8–15", + "setsPerMusclePerWeek": "~16", + "sessionDurationMinutes": 60, + "tags": ["aesthetics", "hypertrophy", "knee-friendly", "push-pull-legs"] + } +} diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 3c62870..ea1bb82 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -12,6 +12,7 @@ import 'services/ml_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; import 'services/workout_provider.dart'; +import 'services/managers/program_manager.dart'; import 'services/api_service.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -64,6 +65,10 @@ class WorkoutLoggerApp extends StatelessWidget { create: (_) => WorkoutProvider(_storageService, mlService: _mlService), ), + // ProgramManager handles training programs and enrollments + ChangeNotifierProvider( + create: (_) => ProgramManager(_storageService), + ), ], child: MaterialApp( title: 'Workout Logger', @@ -97,6 +102,10 @@ class _AppInitializerState extends State { final provider = context.read(); await provider.init(); + // Initialize program manager (storage already initialized above) + final programManager = context.read(); + await programManager.loadPrograms(); + // Fire-and-forget analytics in background final api = context.read(); api.sendHeartbeat(); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index c1dfe1a..7eba1f5 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -393,3 +393,558 @@ class GrowthModel { return slope * sessionNumber + intercept; } } + +// ==================== Training Program Models ==================== + +/// Rep range suggestion for a specific phase and exercise +class ProgramSetScheme { + final String phaseId; + final int sets; + final int minReps; + final int maxReps; + final bool isAmrap; // "AMRAP" — as many reps as possible + final double? suggestedWeightKg; // Optional starting weight hint + + ProgramSetScheme({ + required this.phaseId, + required this.sets, + required this.minReps, + required this.maxReps, + this.isAmrap = false, + this.suggestedWeightKg, + }); + + String get display { + if (isAmrap) return '$sets × AMRAP'; + if (minReps == maxReps) return '$sets×$minReps'; + return '$sets×$minReps-$maxReps'; + } + + Map toJson() => { + 'phaseId': phaseId, + 'sets': sets, + 'minReps': minReps, + 'maxReps': maxReps, + 'isAmrap': isAmrap, + 'suggestedWeightKg': suggestedWeightKg, + }; + + factory ProgramSetScheme.fromJson(Map json) => + ProgramSetScheme( + phaseId: json['phaseId'], + sets: json['sets'], + minReps: json['minReps'], + maxReps: json['maxReps'], + isAmrap: json['isAmrap'] ?? false, + suggestedWeightKg: (json['suggestedWeightKg'] as num?)?.toDouble(), + ); +} + +/// An exercise slot in a program workout day +class ProgramExercise { + final String id; + final String exerciseId; // References Exercise in the library + final String exerciseName; // Stored for display even if exercise not in DB + final int orderIndex; + final String? supersetGroup; // 'A', 'B', 'C' — null means standalone + final List setSchemes; // Per-phase set/rep schemes + final String tempo; // e.g. "3-1-1" (eccentric-pause-concentric) + final int restSeconds; + final String? notes; // Coaching cues / form notes + + ProgramExercise({ + required this.id, + required this.exerciseId, + required this.exerciseName, + required this.orderIndex, + this.supersetGroup, + required this.setSchemes, + this.tempo = '2-0-1', + required this.restSeconds, + this.notes, + }); + + /// Returns the scheme for a given phase ID, falling back to first available + ProgramSetScheme? schemeForPhase(String phaseId) { + final match = setSchemes.where((s) => s.phaseId == phaseId).firstOrNull; + return match ?? setSchemes.firstOrNull; + } + + Map toJson() => { + 'id': id, + 'exerciseId': exerciseId, + 'exerciseName': exerciseName, + 'orderIndex': orderIndex, + 'supersetGroup': supersetGroup, + 'setSchemes': setSchemes.map((s) => s.toJson()).toList(), + 'tempo': tempo, + 'restSeconds': restSeconds, + 'notes': notes, + }; + + factory ProgramExercise.fromJson(Map json) => + ProgramExercise( + id: json['id'], + exerciseId: json['exerciseId'], + exerciseName: json['exerciseName'], + orderIndex: json['orderIndex'], + supersetGroup: json['supersetGroup'], + setSchemes: (json['setSchemes'] as List) + .map((s) => ProgramSetScheme.fromJson(s)) + .toList(), + tempo: json['tempo'] ?? '2-0-1', + restSeconds: json['restSeconds'], + notes: json['notes'], + ); +} + +/// A single day in the program's weekly template +class ProgramWorkoutDay { + final String id; + final String dayOfWeek; // 'monday' … 'sunday' + final String dayType; // 'push', 'pull', 'legs', 'core', 'shoulders', 'rest', 'rehab' + final String name; // e.g. "PUSH (Chest + Triceps + Front Delt)" + final String? description; + final int estimatedDurationMinutes; + final bool isRestDay; + final List exercises; + + ProgramWorkoutDay({ + required this.id, + required this.dayOfWeek, + required this.dayType, + required this.name, + this.description, + this.estimatedDurationMinutes = 60, + this.isRestDay = false, + required this.exercises, + }); + + Map toJson() => { + 'id': id, + 'dayOfWeek': dayOfWeek, + 'dayType': dayType, + 'name': name, + 'description': description, + 'estimatedDurationMinutes': estimatedDurationMinutes, + 'isRestDay': isRestDay, + 'exercises': exercises.map((e) => e.toJson()).toList(), + }; + + factory ProgramWorkoutDay.fromJson(Map json) => + ProgramWorkoutDay( + id: json['id'], + dayOfWeek: json['dayOfWeek'], + dayType: json['dayType'], + name: json['name'], + description: json['description'], + estimatedDurationMinutes: json['estimatedDurationMinutes'] ?? 60, + isRestDay: json['isRestDay'] ?? false, + exercises: (json['exercises'] as List) + .map((e) => ProgramExercise.fromJson(e)) + .toList(), + ); +} + +/// A training phase (e.g. Foundation, Intensify, Peak) +class ProgramPhase { + final String id; + final String name; + final String description; + final int startWeek; + final int endWeek; + final String defaultSetsDisplay; // e.g. "3×10" + final int defaultRestSeconds; + final int rpeTarget; // 1–10 RPE + final bool isDeloadWeek; + + ProgramPhase({ + required this.id, + required this.name, + required this.description, + required this.startWeek, + required this.endWeek, + this.defaultSetsDisplay = '3×10', + this.defaultRestSeconds = 75, + this.rpeTarget = 7, + this.isDeloadWeek = false, + }); + + int get durationWeeks => endWeek - startWeek + 1; + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'startWeek': startWeek, + 'endWeek': endWeek, + 'defaultSetsDisplay': defaultSetsDisplay, + 'defaultRestSeconds': defaultRestSeconds, + 'rpeTarget': rpeTarget, + 'isDeloadWeek': isDeloadWeek, + }; + + factory ProgramPhase.fromJson(Map json) => ProgramPhase( + id: json['id'], + name: json['name'], + description: json['description'], + startWeek: json['startWeek'], + endWeek: json['endWeek'], + defaultSetsDisplay: json['defaultSetsDisplay'] ?? '3×10', + defaultRestSeconds: json['defaultRestSeconds'] ?? 75, + rpeTarget: json['rpeTarget'] ?? 7, + isDeloadWeek: json['isDeloadWeek'] ?? false, + ); +} + +/// A specific week target for an exercise in the overload schedule +class WeekTarget { + final int weekNumber; + final double targetWeightKg; + final int targetReps; + final String? notes; + + WeekTarget({ + required this.weekNumber, + required this.targetWeightKg, + required this.targetReps, + this.notes, + }); + + Map toJson() => { + 'weekNumber': weekNumber, + 'targetWeightKg': targetWeightKg, + 'targetReps': targetReps, + 'notes': notes, + }; + + factory WeekTarget.fromJson(Map json) => WeekTarget( + weekNumber: json['weekNumber'], + targetWeightKg: (json['targetWeightKg'] as num).toDouble(), + targetReps: json['targetReps'], + notes: json['notes'], + ); +} + +/// Progressive overload rule for a single exercise in the program +class ProgressionRule { + final String exerciseId; + final String exerciseName; + final String liftType; // 'compound', 'isolation', 'bodyweight' + final double currentWeightKg; + final double incrementKg; // Weight added per session when target reps are hit + final List weekTargets; + final String? keyNote; // e.g. "3s eccentric every rep" + + ProgressionRule({ + required this.exerciseId, + required this.exerciseName, + required this.liftType, + required this.currentWeightKg, + required this.incrementKg, + required this.weekTargets, + this.keyNote, + }); + + Map toJson() => { + 'exerciseId': exerciseId, + 'exerciseName': exerciseName, + 'liftType': liftType, + 'currentWeightKg': currentWeightKg, + 'incrementKg': incrementKg, + 'weekTargets': weekTargets.map((w) => w.toJson()).toList(), + 'keyNote': keyNote, + }; + + factory ProgressionRule.fromJson(Map json) => + ProgressionRule( + exerciseId: json['exerciseId'], + exerciseName: json['exerciseName'], + liftType: json['liftType'], + currentWeightKg: (json['currentWeightKg'] as num).toDouble(), + incrementKg: (json['incrementKg'] as num).toDouble(), + weekTargets: (json['weekTargets'] as List) + .map((w) => WeekTarget.fromJson(w)) + .toList(), + keyNote: json['keyNote'], + ); +} + +/// Deload week configuration +class DeloadConfig { + final int weekNumber; + final double weightReductionPercent; // e.g. 0.15 for 15% reduction + final int maxSets; + final String? notes; + + DeloadConfig({ + required this.weekNumber, + required this.weightReductionPercent, + required this.maxSets, + this.notes, + }); + + Map toJson() => { + 'weekNumber': weekNumber, + 'weightReductionPercent': weightReductionPercent, + 'maxSets': maxSets, + 'notes': notes, + }; + + factory DeloadConfig.fromJson(Map json) => DeloadConfig( + weekNumber: json['weekNumber'], + weightReductionPercent: (json['weightReductionPercent'] as num).toDouble(), + maxSets: json['maxSets'], + notes: json['notes'], + ); +} + +/// An individual target within a milestone checkpoint +class MilestoneTarget { + final String description; + final String? exerciseId; + final double? targetWeightKg; + final int? targetReps; + + MilestoneTarget({ + required this.description, + this.exerciseId, + this.targetWeightKg, + this.targetReps, + }); + + Map toJson() => { + 'description': description, + 'exerciseId': exerciseId, + 'targetWeightKg': targetWeightKg, + 'targetReps': targetReps, + }; + + factory MilestoneTarget.fromJson(Map json) => + MilestoneTarget( + description: json['description'], + exerciseId: json['exerciseId'], + targetWeightKg: (json['targetWeightKg'] as num?)?.toDouble(), + targetReps: json['targetReps'], + ); +} + +/// A milestone checkpoint at a specific week +class ProgramMilestone { + final String id; + final int weekNumber; + final String title; + final String description; + final String phaseId; + final List targets; + + ProgramMilestone({ + required this.id, + required this.weekNumber, + required this.title, + required this.description, + required this.phaseId, + required this.targets, + }); + + Map toJson() => { + 'id': id, + 'weekNumber': weekNumber, + 'title': title, + 'description': description, + 'phaseId': phaseId, + 'targets': targets.map((t) => t.toJson()).toList(), + }; + + factory ProgramMilestone.fromJson(Map json) => + ProgramMilestone( + id: json['id'], + weekNumber: json['weekNumber'], + title: json['title'], + description: json['description'], + phaseId: json['phaseId'], + targets: (json['targets'] as List) + .map((t) => MilestoneTarget.fromJson(t)) + .toList(), + ); +} + +/// Top-level training program (e.g. a 12-week plan) +class TrainingProgram { + final String id; + final String name; + final String description; + final int durationWeeks; + final int trainingDaysPerWeek; + final String? author; + final DateTime createdAt; + final bool isImported; + final List phases; + final List weeklySchedule; // Template: one week of days + final List milestones; + final List progressionRules; + final List deloadWeeks; + final Map? metadata; // Extra program-specific info + + TrainingProgram({ + required this.id, + required this.name, + required this.description, + required this.durationWeeks, + required this.trainingDaysPerWeek, + this.author, + DateTime? createdAt, + this.isImported = false, + required this.phases, + required this.weeklySchedule, + required this.milestones, + required this.progressionRules, + required this.deloadWeeks, + this.metadata, + }) : createdAt = createdAt ?? DateTime.now(); + + ProgramPhase? phaseForWeek(int weekNumber) { + for (final phase in phases) { + if (weekNumber >= phase.startWeek && weekNumber <= phase.endWeek) { + return phase; + } + } + return null; + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'durationWeeks': durationWeeks, + 'trainingDaysPerWeek': trainingDaysPerWeek, + 'author': author, + 'createdAt': createdAt.toIso8601String(), + 'isImported': isImported, + 'phases': phases.map((p) => p.toJson()).toList(), + 'weeklySchedule': weeklySchedule.map((d) => d.toJson()).toList(), + 'milestones': milestones.map((m) => m.toJson()).toList(), + 'progressionRules': progressionRules.map((r) => r.toJson()).toList(), + 'deloadWeeks': deloadWeeks.map((d) => d.toJson()).toList(), + 'metadata': metadata, + }; + + factory TrainingProgram.fromJson(Map json) => + TrainingProgram( + id: json['id'], + name: json['name'], + description: json['description'], + durationWeeks: json['durationWeeks'], + trainingDaysPerWeek: json['trainingDaysPerWeek'], + author: json['author'], + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : DateTime.now(), + isImported: json['isImported'] ?? false, + phases: (json['phases'] as List) + .map((p) => ProgramPhase.fromJson(p)) + .toList(), + weeklySchedule: (json['weeklySchedule'] as List) + .map((d) => ProgramWorkoutDay.fromJson(d)) + .toList(), + milestones: (json['milestones'] as List) + .map((m) => ProgramMilestone.fromJson(m)) + .toList(), + progressionRules: (json['progressionRules'] as List) + .map((r) => ProgressionRule.fromJson(r)) + .toList(), + deloadWeeks: (json['deloadWeeks'] as List) + .map((d) => DeloadConfig.fromJson(d)) + .toList(), + metadata: json['metadata'] as Map?, + ); +} + +/// User's active enrollment in a training program +class ProgramEnrollment { + final String id; + final String programId; + final DateTime startDate; + int currentWeek; + bool isActive; + bool isCompleted; + final Map completedDays; // key: "week3_monday" → true + final Map currentWeights; // exerciseId → current weight + final Set completedMilestoneIds; + + ProgramEnrollment({ + required this.id, + required this.programId, + required this.startDate, + this.currentWeek = 1, + this.isActive = true, + this.isCompleted = false, + Map? completedDays, + Map? currentWeights, + Set? completedMilestoneIds, + }) : completedDays = completedDays ?? {}, + currentWeights = currentWeights ?? {}, + completedMilestoneIds = completedMilestoneIds ?? {}; + + double get progressPercent => + (currentWeek / 12 * 100).clamp(0, 100); // approximate + + String dayKey(int week, String dayOfWeek) => 'week${week}_$dayOfWeek'; + + bool isDayCompleted(int week, String dayOfWeek) => + completedDays[dayKey(week, dayOfWeek)] ?? false; + + Map toJson() => { + 'id': id, + 'programId': programId, + 'startDate': startDate.toIso8601String(), + 'currentWeek': currentWeek, + 'isActive': isActive, + 'isCompleted': isCompleted, + 'completedDays': completedDays, + 'currentWeights': currentWeights, + 'completedMilestoneIds': completedMilestoneIds.toList(), + }; + + factory ProgramEnrollment.fromJson(Map json) => + ProgramEnrollment( + id: json['id'], + programId: json['programId'], + startDate: DateTime.parse(json['startDate']), + currentWeek: json['currentWeek'] ?? 1, + isActive: json['isActive'] ?? true, + isCompleted: json['isCompleted'] ?? false, + completedDays: Map.from(json['completedDays'] ?? {}), + currentWeights: (json['currentWeights'] as Map?) + ?.map((k, v) => MapEntry(k, (v as num).toDouble())) ?? + {}, + completedMilestoneIds: Set.from( + (json['completedMilestoneIds'] as List?) ?? [], + ), + ); + + ProgramEnrollment copyWith({ + Object? currentWeek = _sentinel, + Object? isActive = _sentinel, + Object? isCompleted = _sentinel, + Object? completedDays = _sentinel, + Object? currentWeights = _sentinel, + Object? completedMilestoneIds = _sentinel, + }) => ProgramEnrollment( + id: id, + programId: programId, + startDate: startDate, + currentWeek: currentWeek == _sentinel ? this.currentWeek : currentWeek as int, + isActive: isActive == _sentinel ? this.isActive : isActive as bool, + isCompleted: isCompleted == _sentinel ? this.isCompleted : isCompleted as bool, + completedDays: completedDays == _sentinel + ? Map.from(this.completedDays) + : completedDays as Map, + currentWeights: currentWeights == _sentinel + ? Map.from(this.currentWeights) + : currentWeights as Map, + completedMilestoneIds: completedMilestoneIds == _sentinel + ? Set.from(this.completedMilestoneIds) + : completedMilestoneIds as Set, + ); +} diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index f9ff39f..2983363 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -10,6 +10,7 @@ import 'workout_flow_screen.dart'; import 'history_screen.dart'; import 'routines_screen.dart'; import 'analytics_screen.dart'; +import 'training_programs_screen.dart'; import 'exercise_library_screen.dart'; import 'settings_screen.dart'; @@ -32,6 +33,7 @@ class _HomeScreenState extends State { DashboardTab(), HistoryScreen(), RoutinesScreen(), + TrainingProgramsScreen(), AnalyticsScreen(), ], ), @@ -55,7 +57,8 @@ class _HomeScreenState extends State { _buildNavItem(0, Icons.home_rounded, 'Home'), _buildNavItem(1, Icons.history_rounded, 'History'), _buildNavItem(2, Icons.list_alt_rounded, 'Routines'), - _buildNavItem(3, Icons.analytics_rounded, 'Analytics'), + _buildNavItem(3, Icons.menu_book_rounded, 'Programs'), + _buildNavItem(4, Icons.analytics_rounded, 'Analytics'), ], ), ), diff --git a/workout-logger/lib/screens/program_detail_screen.dart b/workout-logger/lib/screens/program_detail_screen.dart new file mode 100644 index 0000000..195b68b --- /dev/null +++ b/workout-logger/lib/screens/program_detail_screen.dart @@ -0,0 +1,1403 @@ +// Program Detail Screen +// +// Tabbed view of a training program: Overview, Schedule, Milestones, Overload. +// Shows per-phase exercise tables with sets, reps, tempo, rest, and notes. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/managers/program_manager.dart'; +import '../theme/app_theme.dart'; + +class ProgramDetailScreen extends StatefulWidget { + final TrainingProgram program; + + const ProgramDetailScreen({super.key, required this.program}); + + @override + State createState() => _ProgramDetailScreenState(); +} + +class _ProgramDetailScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final isActive = manager.activeEnrollment?.programId == widget.program.id && + (manager.activeEnrollment?.isActive ?? false); + + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverAppBar( + backgroundColor: AppTheme.surfaceColor, + expandedHeight: 200, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + background: _ProgramHero( + program: widget.program, + isActive: isActive, + ), + ), + actions: [ + if (!isActive) + TextButton.icon( + onPressed: () async { + await manager.enrollInProgram(widget.program.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Started "${widget.program.name}"'), + backgroundColor: AppTheme.primaryColor, + ), + ); + } + }, + icon: const Icon(Icons.play_arrow_rounded, color: Colors.white), + label: const Text( + 'Start', + style: TextStyle(color: Colors.white), + ), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.25), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppTheme.primaryColor.withOpacity(0.5)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.radio_button_checked, color: AppTheme.primaryColor, size: 12), + SizedBox(width: 4), + Text('Active', style: TextStyle(color: AppTheme.primaryColor, fontSize: 12)), + ], + ), + ), + ), + ], + bottom: TabBar( + controller: _tabController, + indicatorColor: AppTheme.primaryColor, + labelColor: AppTheme.primaryColor, + unselectedLabelColor: AppTheme.textSecondary, + labelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + tabs: const [ + Tab(text: 'OVERVIEW'), + Tab(text: 'SCHEDULE'), + Tab(text: 'MILESTONES'), + Tab(text: 'OVERLOAD'), + ], + ), + ), + ], + body: TabBarView( + controller: _tabController, + children: [ + _OverviewTab(program: widget.program), + _ScheduleTab(program: widget.program), + _MilestonesTab(program: widget.program), + _OverloadTab(program: widget.program), + ], + ), + ), + ); + } +} + +// ─────────────────────────── Hero ─────────────────────────── + +class _ProgramHero extends StatelessWidget { + final TrainingProgram program; + final bool isActive; + + const _ProgramHero({required this.program, required this.isActive}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.surfaceColor, + AppTheme.primaryColor.withOpacity(0.15), + ], + ), + ), + padding: const EdgeInsets.fromLTRB(20, 72, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + program.name, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (program.author != null) ...[ + const SizedBox(height: 4), + Text( + 'by ${program.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + _HeroStat(value: '${program.durationWeeks}w', label: 'Duration'), + const SizedBox(width: 20), + _HeroStat( + value: '${program.trainingDaysPerWeek}×', + label: 'Per Week', + ), + const SizedBox(width: 20), + _HeroStat( + value: '${program.phases.length}', + label: 'Phases', + ), + const SizedBox(width: 20), + _HeroStat( + value: '${program.milestones.length}', + label: 'Milestones', + ), + ], + ), + ], + ), + ); + } +} + +class _HeroStat extends StatelessWidget { + final String value; + final String label; + + const _HeroStat({required this.value, required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ); + } +} + +// ─────────────────────────── Overview Tab ─────────────────────────── + +class _OverviewTab extends StatelessWidget { + final TrainingProgram program; + + const _OverviewTab({required this.program}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(20), + children: [ + // Description + if (program.description.isNotEmpty) ...[ + Text( + program.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + height: 1.6, + ), + ), + const SizedBox(height: 24), + ], + + // Phases + Text( + 'Training Phases', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ...program.phases.asMap().entries.map((entry) { + final i = entry.key; + final phase = entry.value; + final color = _phaseColor(i); + return _PhaseCard(phase: phase, color: color); + }), + + // Deload weeks + if (program.deloadWeeks.isNotEmpty) ...[ + const SizedBox(height: 20), + Text( + 'Deload Weeks', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: const Color(0xFFC87C1A).withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFC87C1A), + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Programmed Deloads — Do Not Skip', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: const Color(0xFFC87C1A), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + ...program.deloadWeeks.map((d) { + final reduction = (d.weightReductionPercent * 100).round(); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 60, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFC87C1A).withOpacity(0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'W${d.weekNumber}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC87C1A), + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '–$reduction% weight · max ${d.maxSets} sets' + '${d.notes != null ? " · ${d.notes}" : ""}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ], + ); + } + + Color _phaseColor(int index) { + const colors = [ + Color(0xFFC8491A), + Color(0xFF1A6BC8), + Color(0xFF1A8C4E), + ]; + return colors[index % colors.length]; + } +} + +class _PhaseCard extends StatelessWidget { + final ProgramPhase phase; + final Color color; + + const _PhaseCard({required this.phase, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'W${phase.startWeek}–${phase.endWeek}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + phase.name, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + phase.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + _PhaseStat(label: 'Sets×Reps', value: phase.defaultSetsDisplay), + const SizedBox(width: 16), + _PhaseStat( + label: 'Rest', + value: '${phase.defaultRestSeconds}s', + ), + const SizedBox(width: 16), + _PhaseStat(label: 'RPE', value: '${phase.rpeTarget}'), + if (phase.isDeloadWeek) ...[ + const SizedBox(width: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFC87C1A).withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'DELOAD', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC87C1A), + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ], + ), + ); + } +} + +class _PhaseStat extends StatelessWidget { + final String label; + final String value; + + const _PhaseStat({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + fontSize: 10, + ), + ), + ], + ); + } +} + +// ─────────────────────────── Schedule Tab ─────────────────────────── + +class _ScheduleTab extends StatefulWidget { + final TrainingProgram program; + + const _ScheduleTab({required this.program}); + + @override + State<_ScheduleTab> createState() => _ScheduleTabState(); +} + +class _ScheduleTabState extends State<_ScheduleTab> { + int _selectedDayIndex = 0; + + static const _dayOrder = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ]; + + static const _dayLabels = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; + + List get _sortedDays { + final days = List.from( + widget.program.weeklySchedule, + ); + days.sort( + (a, b) => + _dayOrder.indexOf(a.dayOfWeek).compareTo( + _dayOrder.indexOf(b.dayOfWeek), + ), + ); + return days; + } + + @override + Widget build(BuildContext context) { + final days = _sortedDays; + if (days.isEmpty) { + return const Center(child: Text('No weekly schedule defined.')); + } + + final selectedDay = days[_selectedDayIndex.clamp(0, days.length - 1)]; + + return Column( + children: [ + // Day selector strip + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: days.length, + itemBuilder: (context, i) { + final day = days[i]; + final isSelected = i == _selectedDayIndex; + final dayLabel = _dayLabels[ + _dayOrder.indexOf(day.dayOfWeek).clamp(0, 6)]; + final color = _dayTypeColor(day.dayType); + return GestureDetector( + onTap: () => setState(() => _selectedDayIndex = i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 56, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: isSelected + ? color.withOpacity(0.2) + : AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: isSelected + ? color + : AppTheme.textSecondary.withOpacity(0.15), + width: isSelected ? 1.5 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + dayLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: isSelected ? color : AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color.withOpacity(day.isRestDay ? 0.3 : 0.7), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ); + }, + ), + ), + // Day detail + Expanded( + child: _DayDetail( + day: selectedDay, + phases: widget.program.phases, + ), + ), + ], + ); + } + + Color _dayTypeColor(String type) { + switch (type) { + case 'push': + return const Color(0xFFC8491A); + case 'pull': + return const Color(0xFF1A6BC8); + case 'legs': + return const Color(0xFF6B1A8C); + case 'core': + return const Color(0xFF1A7A8C); + case 'shoulders': + return const Color(0xFF8C6B1A); + case 'arms': + return const Color(0xFF1A8C4E); + default: + return AppTheme.textSecondary; + } + } +} + +class _DayDetail extends StatelessWidget { + final ProgramWorkoutDay day; + final List phases; + + const _DayDetail({required this.day, required this.phases}); + + @override + Widget build(BuildContext context) { + if (day.isRestDay) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.self_improvement_rounded, + size: 64, + color: AppTheme.textSecondary.withOpacity(0.3), + ), + const SizedBox(height: 12), + Text( + day.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + if (day.description != null) ...[ + const SizedBox(height: 6), + Text( + day.description!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary.withOpacity(0.7), + ), + ), + ], + ], + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Day header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + day.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (day.description != null) + Text( + day.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + if (day.estimatedDurationMinutes > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_rounded, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + '~${day.estimatedDurationMinutes} min', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Exercise table + if (day.exercises.isEmpty) + Text( + 'No exercises defined.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ) + else + ...day.exercises.map( + (ex) => _ExerciseRow(exercise: ex, phases: phases), + ), + ], + ); + } +} + +class _ExerciseRow extends StatelessWidget { + final ProgramExercise exercise; + final List phases; + + const _ExerciseRow({required this.exercise, required this.phases}); + + @override + Widget build(BuildContext context) { + final isSuperset = exercise.supersetGroup != null; + final supersetColor = _supersetColor(exercise.supersetGroup); + + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: isSuperset + ? Border.all(color: supersetColor.withOpacity(0.3)) + : null, + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Exercise name + superset badge + Row( + children: [ + if (isSuperset) ...[ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: supersetColor.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + exercise.supersetGroup!, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: supersetColor, + ), + ), + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + exercise.exerciseName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Per-phase set schemes + if (phases.isNotEmpty) + Wrap( + spacing: 6, + runSpacing: 4, + children: phases.asMap().entries.map((entry) { + final i = entry.key; + final phase = entry.value; + final scheme = exercise.schemeForPhase(phase.id); + if (scheme == null) return const SizedBox.shrink(); + final color = _phaseColor(i); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.25)), + ), + child: Text( + 'P${i + 1}: ${scheme.display}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ); + }).toList(), + ) + else if (exercise.setSchemes.isNotEmpty) + Text( + exercise.setSchemes.first.display, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + // Tempo / Rest / Superset info + Row( + children: [ + _ExTag(icon: Icons.speed_rounded, label: exercise.tempo), + const SizedBox(width: 10), + _ExTag( + icon: Icons.timer_outlined, + label: exercise.restSeconds > 0 + ? '${exercise.restSeconds}s rest' + : 'no rest', + ), + if (isSuperset) ...[ + const SizedBox(width: 10), + _ExTag( + icon: Icons.link_rounded, + label: 'Superset ${exercise.supersetGroup}', + ), + ], + ], + ), + // Notes + if (exercise.notes != null && exercise.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + exercise.notes!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + fontStyle: FontStyle.italic, + height: 1.4, + ), + ), + ], + ], + ), + ), + ); + } + + Color _supersetColor(String? group) { + switch (group) { + case 'A': + return const Color(0xFFC8491A); + case 'B': + return const Color(0xFF1A6BC8); + case 'C': + return const Color(0xFF1A8C4E); + default: + return AppTheme.primaryColor; + } + } + + Color _phaseColor(int index) { + const colors = [ + Color(0xFFC8491A), + Color(0xFF1A6BC8), + Color(0xFF1A8C4E), + ]; + return colors[index % colors.length]; + } +} + +class _ExTag extends StatelessWidget { + final IconData icon; + final String label; + + const _ExTag({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: AppTheme.textSecondary), + const SizedBox(width: 3), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ); + } +} + +// ─────────────────────────── Milestones Tab ─────────────────────────── + +class _MilestonesTab extends StatelessWidget { + final TrainingProgram program; + + const _MilestonesTab({required this.program}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final enrollment = manager.activeEnrollment?.programId == program.id + ? manager.activeEnrollment + : null; + + if (program.milestones.isEmpty) { + return const Center(child: Text('No milestones defined.')); + } + + final sorted = List.from(program.milestones) + ..sort((a, b) => a.weekNumber.compareTo(b.weekNumber)); + + return ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: sorted.length, + itemBuilder: (context, i) { + final milestone = sorted[i]; + final isCompleted = + enrollment?.completedMilestoneIds.contains(milestone.id) ?? false; + final phase = program.phases.firstWhere( + (p) => p.id == milestone.phaseId, + orElse: () => program.phases.first, + ); + final phaseIndex = program.phases.indexOf(phase); + final color = _phaseColor(phaseIndex); + + return _MilestoneCard( + milestone: milestone, + color: color, + isCompleted: isCompleted, + canComplete: enrollment != null, + onComplete: () async { + await manager.completeMilestone(milestone.id); + }, + ); + }, + ); + } + + Color _phaseColor(int index) { + const colors = [ + Color(0xFFC8491A), + Color(0xFF1A6BC8), + Color(0xFF1A8C4E), + AppTheme.textPrimary, + ]; + return colors[index.clamp(0, colors.length - 1)]; + } +} + +class _MilestoneCard extends StatelessWidget { + final ProgramMilestone milestone; + final Color color; + final bool isCompleted; + final bool canComplete; + final VoidCallback onComplete; + + const _MilestoneCard({ + required this.milestone, + required this.color, + required this.isCompleted, + required this.canComplete, + required this.onComplete, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline indicator + Column( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted ? color : AppTheme.cardColor, + border: Border.all(color: color, width: 2), + ), + child: isCompleted + ? const Icon(Icons.check, size: 14, color: Colors.white) + : null, + ), + Container( + width: 2, + height: 20, + color: color.withOpacity(0.2), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isCompleted + ? color.withOpacity(0.4) + : color.withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '// WEEK ${milestone.weekNumber}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 4), + Text( + milestone.title.toUpperCase(), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + milestone.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + height: 1.5, + ), + ), + if (milestone.targets.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: milestone.targets.map((t) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.25)), + ), + child: Text( + t.description, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ], + if (canComplete && !isCompleted) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onComplete, + style: TextButton.styleFrom( + foregroundColor: color, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + ), + child: const Text('Mark Complete ✓'), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +// ─────────────────────────── Overload Tab ─────────────────────────── + +class _OverloadTab extends StatelessWidget { + final TrainingProgram program; + + const _OverloadTab({required this.program}); + + @override + Widget build(BuildContext context) { + if (program.progressionRules.isEmpty) { + return const Center(child: Text('No overload schedule defined.')); + } + + return ListView( + padding: const EdgeInsets.all(20), + children: [ + // Overload rules summary + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Progressive Overload Rules', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + _OverloadRule( + color: const Color(0xFFC8491A), + title: 'Compound Lifts', + rule: 'Add +2.5kg when all sets at top of rep range.', + ), + const SizedBox(height: 8), + _OverloadRule( + color: const Color(0xFF1A6BC8), + title: 'Isolation Lifts', + rule: 'Add +1.25kg or +1 rep. Strict form required.', + ), + const SizedBox(height: 8), + _OverloadRule( + color: const Color(0xFF1A8C4E), + title: 'Bodyweight / Timed', + rule: '+1 rep/week for pull-ups, +5s/week for holds.', + ), + ], + ), + ), + const SizedBox(height: 20), + Text( + 'Per-Exercise Schedule', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ...program.progressionRules.map( + (rule) => _ProgressionCard(rule: rule, phases: program.phases), + ), + ], + ); + } +} + +class _OverloadRule extends StatelessWidget { + final Color color; + final String title; + final String rule; + + const _OverloadRule({ + required this.color, + required this.title, + required this.rule, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(top: 3), + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + rule, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _ProgressionCard extends StatelessWidget { + final ProgressionRule rule; + final List phases; + + const _ProgressionCard({required this.rule, required this.phases}); + + @override + Widget build(BuildContext context) { + final liftColor = rule.liftType == 'compound' + ? const Color(0xFFC8491A) + : rule.liftType == 'isolation' + ? const Color(0xFF1A6BC8) + : const Color(0xFF1A8C4E); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: liftColor.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: liftColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + rule.exerciseName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: liftColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + rule.liftType.toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: liftColor, + ), + ), + ), + ], + ), + ), + // Week targets table + if (rule.weekTargets.isNotEmpty) ...[ + Divider(height: 1, color: AppTheme.textSecondary.withOpacity(0.1)), + _WeightTable( + currentWeight: rule.currentWeightKg, + increment: rule.incrementKg, + targets: rule.weekTargets, + keyNote: rule.keyNote, + ), + ], + ], + ), + ); + } +} + +class _WeightTable extends StatelessWidget { + final double currentWeight; + final double increment; + final List targets; + final String? keyNote; + + const _WeightTable({ + required this.currentWeight, + required this.increment, + required this.targets, + this.keyNote, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + _TableRow(label: 'Starting', value: '${currentWeight}kg', isHeader: false), + ...targets.map( + (t) => _TableRow( + label: 'W${t.weekNumber} target', + value: '${t.targetWeightKg}kg × ${t.targetReps}', + isHeader: false, + ), + ), + _TableRow( + label: 'Increment', + value: '+${increment}kg/session', + isHeader: false, + ), + if (keyNote != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + const Icon( + Icons.tips_and_updates_rounded, + size: 14, + color: AppTheme.secondaryColor, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + keyNote!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.secondaryColor, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _TableRow extends StatelessWidget { + final String label; + final String value; + final bool isHeader; + + const _TableRow({ + required this.label, + required this.value, + required this.isHeader, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/training_programs_screen.dart b/workout-logger/lib/screens/training_programs_screen.dart new file mode 100644 index 0000000..827bc06 --- /dev/null +++ b/workout-logger/lib/screens/training_programs_screen.dart @@ -0,0 +1,778 @@ +// Training Programs Screen +// +// Browse, import, and manage structured training programs. +// Supports JSON import from file or clipboard. + +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/managers/program_manager.dart'; +import '../theme/app_theme.dart'; +import 'program_detail_screen.dart'; + +class TrainingProgramsScreen extends StatelessWidget { + const TrainingProgramsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header(), + Expanded(child: _ProgramList()), + ], + ), + ), + floatingActionButton: _ImportFAB(), + ); + } +} + +class _Header extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.menu_book_rounded, + color: AppTheme.primaryColor, + size: 28, + ), + const SizedBox(width: 12), + Text( + 'Training Programs', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Structured multi-week plans with deload weeks, progressive overload, and milestones.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + _ActiveEnrollmentCard(), + ], + ), + ); + } +} + +class _ActiveEnrollmentCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final enrollment = manager.activeEnrollment; + final program = manager.activeProgram; + + if (enrollment == null || program == null) return const SizedBox.shrink(); + + final phase = program.phaseForWeek(enrollment.currentWeek); + final completedCount = enrollment.completedDays.values.where((v) => v).length; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.primaryColor.withOpacity(0.15), + AppTheme.secondaryColor.withOpacity(0.08), + ], + ), + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.play_circle_filled_rounded, + color: AppTheme.primaryColor, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'ACTIVE PROGRAM', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + program.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (phase != null) ...[ + const SizedBox(height: 4), + Text( + 'Week ${enrollment.currentWeek} · ${phase.name}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + const SizedBox(height: 12), + // Progress bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: enrollment.currentWeek / program.durationWeeks, + backgroundColor: AppTheme.cardColor, + valueColor: const AlwaysStoppedAnimation( + AppTheme.primaryColor, + ), + minHeight: 6, + ), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Week ${enrollment.currentWeek} of ${program.durationWeeks}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + Text( + '$completedCount sessions logged', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProgramDetailScreen(program: program), + ), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.primaryColor), + foregroundColor: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: const Text('View Plan'), + ), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: AppTheme.surfaceColor, + title: const Text('Advance to Next Week?'), + content: const Text( + 'Mark this week as complete and move to the next week.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Advance'), + ), + ], + ), + ); + if (confirm == true && context.mounted) { + await context.read().advanceWeek(); + } + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppTheme.textSecondary.withOpacity(0.4)), + foregroundColor: AppTheme.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + ), + child: const Text('Next Week →'), + ), + ], + ), + ], + ), + ); + } +} + +class _ProgramList extends StatelessWidget { + @override + Widget build(BuildContext context) { + final programs = context.watch().programs; + + if (programs.isEmpty) { + return _EmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 100), + itemCount: programs.length, + itemBuilder: (context, index) => _ProgramCard(program: programs[index]), + ); + } +} + +class _EmptyState extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.calendar_month_outlined, + size: 72, + color: AppTheme.textSecondary.withOpacity(0.4), + ), + const SizedBox(height: 16), + Text( + 'No Training Programs', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + 'Import a JSON training plan or paste one from clipboard.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary.withOpacity(0.7), + ), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () => _showImportDialog(context), + icon: const Icon(Icons.upload_rounded), + label: const Text('Import Program'), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.primaryColor), + foregroundColor: AppTheme.primaryColor, + ), + ), + ], + ), + ), + ); + } +} + +class _ProgramCard extends StatelessWidget { + final TrainingProgram program; + const _ProgramCard({required this.program}); + + @override + Widget build(BuildContext context) { + final manager = context.watch(); + final isActive = manager.activeEnrollment?.programId == program.id && + (manager.activeEnrollment?.isActive ?? false); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: isActive + ? Border.all(color: AppTheme.primaryColor.withOpacity(0.5)) + : null, + ), + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProgramDetailScreen(program: program), + ), + ), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (isActive) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'ACTIVE', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ], + if (program.isImported) + Icon( + Icons.download_done_rounded, + size: 14, + color: AppTheme.textSecondary.withOpacity(0.6), + ), + ], + ), + if (isActive || program.isImported) + const SizedBox(height: 4), + Text( + program.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (program.author != null) ...[ + const SizedBox(height: 2), + Text( + 'by ${program.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ), + ), + PopupMenuButton( + icon: Icon( + Icons.more_vert_rounded, + color: AppTheme.textSecondary, + ), + color: AppTheme.surfaceColor, + onSelected: (value) => + _handleMenuAction(context, value, manager), + itemBuilder: (_) => [ + if (!isActive) + const PopupMenuItem( + value: 'start', + child: Row( + children: [ + Icon(Icons.play_arrow_rounded, size: 18), + SizedBox(width: 8), + Text('Start Program'), + ], + ), + ), + if (isActive) + const PopupMenuItem( + value: 'leave', + child: Row( + children: [ + Icon(Icons.stop_circle_outlined, size: 18), + SizedBox(width: 8), + Text('Leave Program'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline_rounded, size: 18, color: Colors.red), + SizedBox(width: 8), + Text('Delete', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Text( + program.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + // Stats row + Row( + children: [ + _StatChip( + icon: Icons.calendar_today_rounded, + label: '${program.durationWeeks} weeks', + ), + const SizedBox(width: 8), + _StatChip( + icon: Icons.fitness_center_rounded, + label: '${program.trainingDaysPerWeek}×/week', + ), + const SizedBox(width: 8), + _StatChip( + icon: Icons.layers_rounded, + label: '${program.phases.length} phases', + ), + ], + ), + // Phase pills + if (program.phases.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + children: program.phases.map((phase) { + final color = _phaseColor(program.phases.indexOf(phase)); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + 'W${phase.startWeek}–${phase.endWeek}: ${phase.name}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ); + } + + Color _phaseColor(int index) { + const colors = [ + Color(0xFFC8491A), + Color(0xFF1A6BC8), + Color(0xFF1A8C4E), + ]; + return colors[index % colors.length]; + } + + Future _handleMenuAction( + BuildContext context, + String action, + ProgramManager manager, + ) async { + switch (action) { + case 'start': + await manager.enrollInProgram(program.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Started "${program.name}"'), + backgroundColor: AppTheme.primaryColor, + ), + ); + } + case 'leave': + await manager.leaveProgram(); + case 'delete': + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: AppTheme.surfaceColor, + title: const Text('Delete Program?'), + content: Text('Remove "${program.name}" permanently?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm == true && context.mounted) { + await manager.deleteProgram(program.id); + } + } + } +} + +class _StatChip extends StatelessWidget { + final IconData icon; + final String label; + + const _StatChip({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ); + } +} + +class _ImportFAB extends StatelessWidget { + @override + Widget build(BuildContext context) { + return FloatingActionButton.extended( + onPressed: () => _showImportDialog(context), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + icon: const Icon(Icons.upload_rounded), + label: const Text('Import'), + ); + } +} + +void _showImportDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.surfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _ImportBottomSheet(), + ); +} + +class _ImportBottomSheet extends StatefulWidget { + @override + State<_ImportBottomSheet> createState() => _ImportBottomSheetState(); +} + +class _ImportBottomSheetState extends State<_ImportBottomSheet> { + final _controller = TextEditingController(); + bool _isLoading = false; + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _pasteFromClipboard() async { + final data = await Clipboard.getData('text/plain'); + if (data?.text != null) { + _controller.text = data!.text!; + } + } + + Future _import() async { + final text = _controller.text.trim(); + if (text.isEmpty) { + setState(() => _error = 'Paste JSON first'); + return; + } + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + // Validate JSON structure before importing + final decoded = jsonDecode(text); + if (decoded is! Map || !decoded.containsKey('name')) { + throw const FormatException( + 'Invalid format. Must be a training program JSON with a "name" field.', + ); + } + + final manager = context.read(); + final program = await manager.importProgramFromJson(text); + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Imported "${program.name}"'), + backgroundColor: AppTheme.primaryColor, + ), + ); + } + } catch (e) { + setState(() { + _isLoading = false; + _error = e.toString().replaceFirst('FormatException: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB( + 20, + 20, + 20, + MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Import Training Program', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Paste a training program JSON below. Must include phases, weekly schedule, exercises with set schemes, tempo, and rest times.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: _error != null + ? Colors.red.withOpacity(0.5) + : AppTheme.textSecondary.withOpacity(0.2), + ), + ), + child: TextField( + controller: _controller, + maxLines: 10, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + hintText: '{ "name": "My 12-Week Plan", ... }', + hintStyle: TextStyle( + color: AppTheme.textSecondary.withOpacity(0.5), + fontFamily: 'monospace', + fontSize: 12, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.all(12), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + onPressed: _pasteFromClipboard, + icon: const Icon(Icons.paste_rounded, size: 16), + label: const Text('Paste'), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + side: BorderSide( + color: AppTheme.textSecondary.withOpacity(0.4), + ), + ), + ), + const Spacer(), + ElevatedButton( + onPressed: _isLoading ? null : _import, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Import'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index c8eb6fc..af44124 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -68,4 +68,18 @@ abstract class IStorageService { // ==================== STATS ==================== Future> getQuickStats(); + + // ==================== TRAINING PROGRAMS ==================== + + Future saveTrainingProgram(TrainingProgram program); + Future> getAllTrainingPrograms(); + Future getTrainingProgram(String id); + Future deleteTrainingProgram(String id); + + // ==================== PROGRAM ENROLLMENTS ==================== + + Future saveEnrollment(ProgramEnrollment enrollment); + Future> getAllEnrollments(); + Future getActiveEnrollment(); + Future deleteEnrollment(String id); } diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index ebf68c3..749c0d9 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -15,3 +15,4 @@ export 'routine_manager.dart'; export 'exercise_manager.dart'; export 'target_manager.dart'; export 'analytics_manager.dart'; +export 'program_manager.dart'; diff --git a/workout-logger/lib/services/managers/program_manager.dart b/workout-logger/lib/services/managers/program_manager.dart new file mode 100644 index 0000000..6fe4a39 --- /dev/null +++ b/workout-logger/lib/services/managers/program_manager.dart @@ -0,0 +1,197 @@ +// Program Manager (Single Responsibility Principle) +// +// Responsible ONLY for managing training programs and enrollments. +// Handles: import from JSON, CRUD on programs, enrollment lifecycle. + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +class ProgramManager extends ChangeNotifier { + final IStorageService _storage; + final Uuid _uuid = const Uuid(); + + List _programs = []; + ProgramEnrollment? _activeEnrollment; + + ProgramManager(this._storage); + + List get programs => List.unmodifiable(_programs); + ProgramEnrollment? get activeEnrollment => _activeEnrollment; + int get totalPrograms => _programs.length; + + TrainingProgram? get activeProgram { + if (_activeEnrollment == null) return null; + try { + return _programs.firstWhere((p) => p.id == _activeEnrollment!.programId); + } catch (_) { + return null; + } + } + + /// Load all programs and active enrollment from storage + Future loadPrograms() async { + _programs = await _storage.getAllTrainingPrograms(); + _activeEnrollment = await _storage.getActiveEnrollment(); + notifyListeners(); + } + + /// Get a program by ID + TrainingProgram? getProgram(String id) { + try { + return _programs.firstWhere((p) => p.id == id); + } catch (_) { + return null; + } + } + + /// Import a training program from JSON string + /// + /// The JSON must conform to the TrainingProgram.toJson() schema. + /// Returns the imported program, or throws if invalid. + Future importProgramFromJson(String jsonString) async { + final Map data = + jsonDecode(jsonString) as Map; + + // Assign a fresh ID so imports never collide with existing programs + data['id'] = _uuid.v4(); + data['isImported'] = true; + data['createdAt'] = DateTime.now().toIso8601String(); + + final program = TrainingProgram.fromJson(data); + await _storage.saveTrainingProgram(program); + _programs.insert(0, program); + notifyListeners(); + return program; + } + + /// Save a manually created program + Future createProgram(TrainingProgram program) async { + final newProgram = TrainingProgram.fromJson({ + ...program.toJson(), + 'id': _uuid.v4(), + 'createdAt': DateTime.now().toIso8601String(), + }); + await _storage.saveTrainingProgram(newProgram); + _programs.insert(0, newProgram); + notifyListeners(); + return newProgram; + } + + /// Delete a program (also cancels enrollment if active for that program) + Future deleteProgram(String id) async { + await _storage.deleteTrainingProgram(id); + _programs.removeWhere((p) => p.id == id); + if (_activeEnrollment?.programId == id) { + await _storage.deleteEnrollment(_activeEnrollment!.id); + _activeEnrollment = null; + } + notifyListeners(); + } + + /// Start enrollment in a program (deactivates any current enrollment) + Future enrollInProgram(String programId) async { + // Deactivate existing active enrollment + if (_activeEnrollment != null && _activeEnrollment!.isActive) { + final deactivated = _activeEnrollment!.copyWith(isActive: false); + await _storage.saveEnrollment(deactivated); + } + + final enrollment = ProgramEnrollment( + id: _uuid.v4(), + programId: programId, + startDate: DateTime.now(), + currentWeek: 1, + isActive: true, + ); + await _storage.saveEnrollment(enrollment); + _activeEnrollment = enrollment; + notifyListeners(); + return enrollment; + } + + /// Mark a workout day as completed for the current week + Future markDayCompleted(String dayOfWeek) async { + if (_activeEnrollment == null) return; + final updated = Map.from(_activeEnrollment!.completedDays); + updated[_activeEnrollment!.dayKey(_activeEnrollment!.currentWeek, dayOfWeek)] = true; + _activeEnrollment = _activeEnrollment!.copyWith(completedDays: updated); + await _storage.saveEnrollment(_activeEnrollment!); + notifyListeners(); + } + + /// Advance to the next week of the program + Future advanceWeek() async { + if (_activeEnrollment == null) return; + final program = activeProgram; + if (program == null) return; + + final nextWeek = _activeEnrollment!.currentWeek + 1; + if (nextWeek > program.durationWeeks) { + _activeEnrollment = _activeEnrollment!.copyWith( + isCompleted: true, + isActive: false, + ); + } else { + _activeEnrollment = _activeEnrollment!.copyWith(currentWeek: nextWeek); + } + await _storage.saveEnrollment(_activeEnrollment!); + notifyListeners(); + } + + /// Update the working weight for an exercise in the active enrollment + Future updateWorkingWeight(String exerciseId, double weightKg) async { + if (_activeEnrollment == null) return; + final updated = Map.from(_activeEnrollment!.currentWeights); + updated[exerciseId] = weightKg; + _activeEnrollment = _activeEnrollment!.copyWith(currentWeights: updated); + await _storage.saveEnrollment(_activeEnrollment!); + notifyListeners(); + } + + /// Mark a milestone as completed + Future completeMilestone(String milestoneId) async { + if (_activeEnrollment == null) return; + final updated = Set.from(_activeEnrollment!.completedMilestoneIds) + ..add(milestoneId); + _activeEnrollment = _activeEnrollment!.copyWith( + completedMilestoneIds: updated, + ); + await _storage.saveEnrollment(_activeEnrollment!); + notifyListeners(); + } + + /// Leave / cancel the current enrollment + Future leaveProgram() async { + if (_activeEnrollment == null) return; + final deactivated = _activeEnrollment!.copyWith(isActive: false); + await _storage.saveEnrollment(deactivated); + _activeEnrollment = null; + notifyListeners(); + } + + /// Returns the active phase for the current enrollment week + ProgramPhase? get currentPhase { + final program = activeProgram; + if (program == null || _activeEnrollment == null) return null; + return program.phaseForWeek(_activeEnrollment!.currentWeek); + } + + /// Returns deload config for a given week if applicable + DeloadConfig? deloadForWeek(TrainingProgram program, int week) { + try { + return program.deloadWeeks.firstWhere((d) => d.weekNumber == week); + } catch (_) { + return null; + } + } + + /// Export a program back to JSON string + String exportProgramToJson(String programId) { + final program = getProgram(programId); + if (program == null) throw Exception('Program not found: $programId'); + return const JsonEncoder.withIndent(' ').convert(program.toJson()); + } +} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 18ffa74..d4cf209 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -23,6 +23,8 @@ class StorageService implements IStorageService { static const String _muscleGroupsBox = 'muscle_groups'; static const String _customExercisesBox = 'custom_exercises'; static const String _settingsBox = 'settings'; + static const String _trainingProgramsBox = 'training_programs'; + static const String _enrollmentsBox = 'program_enrollments'; late Box _sessionsBox; late Box _routinesBoxInstance; @@ -30,6 +32,8 @@ class StorageService implements IStorageService { late Box _muscleGroupsBoxInstance; late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; + late Box _trainingProgramsBoxInstance; + late Box _enrollmentsBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -63,6 +67,10 @@ class StorageService implements IStorageService { _customExercisesBox, ); _settingsBoxInstance = await Hive.openBox(_settingsBox); + _trainingProgramsBoxInstance = await Hive.openBox( + _trainingProgramsBox, + ); + _enrollmentsBoxInstance = await Hive.openBox(_enrollmentsBox); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -443,6 +451,72 @@ class StorageService implements IStorageService { } } + // ==================== TRAINING PROGRAMS ==================== + + @override + Future saveTrainingProgram(TrainingProgram program) async { + await _trainingProgramsBoxInstance.put( + program.id, + jsonEncode(program.toJson()), + ); + } + + @override + Future> getAllTrainingPrograms() async { + final programs = []; + for (var json in _trainingProgramsBoxInstance.values) { + programs.add(TrainingProgram.fromJson(jsonDecode(json))); + } + programs.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return programs; + } + + @override + Future getTrainingProgram(String id) async { + final json = _trainingProgramsBoxInstance.get(id); + if (json == null) return null; + return TrainingProgram.fromJson(jsonDecode(json)); + } + + @override + Future deleteTrainingProgram(String id) async { + await _trainingProgramsBoxInstance.delete(id); + } + + // ==================== PROGRAM ENROLLMENTS ==================== + + @override + Future saveEnrollment(ProgramEnrollment enrollment) async { + await _enrollmentsBoxInstance.put( + enrollment.id, + jsonEncode(enrollment.toJson()), + ); + } + + @override + Future> getAllEnrollments() async { + final enrollments = []; + for (var json in _enrollmentsBoxInstance.values) { + enrollments.add(ProgramEnrollment.fromJson(jsonDecode(json))); + } + return enrollments; + } + + @override + Future getActiveEnrollment() async { + final all = await getAllEnrollments(); + try { + return all.firstWhere((e) => e.isActive); + } catch (_) { + return null; + } + } + + @override + Future deleteEnrollment(String id) async { + await _enrollmentsBoxInstance.delete(id); + } + // ==================== STATS ==================== @override