diff --git a/rhythm_coach/lib/models/posture.dart b/rhythm_coach/lib/models/posture.dart new file mode 100644 index 0000000..ce54ebd --- /dev/null +++ b/rhythm_coach/lib/models/posture.dart @@ -0,0 +1,88 @@ +/// Posture physique imposée par la coach pendant une session. +/// +/// À ne pas confondre avec [Position] (`session_step.dart`), qui est la +/// profondeur anatomique (tip → full + balls). La posture est de la **mise +/// en scène pure** : elle n'affecte ni les steps ni la difficulté, et change +/// uniquement à l'intro ou pendant un [ScriptedBreak], jamais en plein effort. +/// +/// `free` (« confort, au choix ») est toujours disponible et n'a pas de +/// milestone : c'est le défaut bas niveau, aucune contrainte. Les autres +/// postures se débloquent via leur milestone d'introduction dédiée +/// (`intro_posture_*`) — cf. spec locale `specs/scripted_breaks.md`. +enum Posture { + /// Aucune posture imposée — l'utilisatrice s'installe comme elle veut. + free, + sitting, + standing, + kneeling, + allFours, + onBack; + + /// Clé d'unlock carrière associée, ou `null` pour [free] (toujours + /// disponible). Sert à filtrer les postures débloquées contre les unlocks + /// acquis. Chaîne stable (≠ enum `UnlockKey` de `career/` pour ne pas faire + /// dépendre `models/` de `career/`). + String? get unlockKey => switch (this) { + Posture.free => null, + Posture.sitting => 'posture_sitting', + Posture.standing => 'posture_standing', + Posture.kneeling => 'posture_kneeling', + Posture.allFours => 'posture_all_fours', + Posture.onBack => 'posture_on_back', + }; + + static Posture fromString(String? raw) { + if (raw == null) return Posture.free; + return switch (raw.toLowerCase()) { + 'free' => Posture.free, + 'sitting' => Posture.sitting, + 'standing' => Posture.standing, + 'kneeling' => Posture.kneeling, + 'all_fours' || 'allfours' => Posture.allFours, + 'on_back' || 'onback' => Posture.onBack, + _ => Posture.free, + }; + } + + String get serialized => switch (this) { + Posture.allFours => 'all_fours', + Posture.onBack => 'on_back', + _ => name, + }; +} + +/// Pause active scénarisée insérée sur les sessions longues. Pendant un +/// break, le moteur d'effort est gelé et la coach donne des ordres espacés +/// ([orders]) ; un break peut aussi changer de posture à la reprise +/// ([newPose]). +/// +/// Transient comme `Challenge` : généré dynamiquement par le générateur de +/// carrière, jamais sérialisé dans un fichier de session. À ne pas confondre +/// avec les mini-vagues (`_buildMiniWave`), qui sont des micro-finishes +/// d'accélération, pas des pauses de récup. +class ScriptedBreak { + /// Seconde (depuis le début de la session) où le break démarre. + final int time; + + /// Durée du break en secondes (typiquement 60–120). + final int durationSeconds; + + /// Posture appliquée à la reprise, ou `null` si le break ne change pas de + /// posture (récup pure : 2ᵉ break d'une session très longue, ou aucune + /// posture débloquée). + final Posture? newPose; + + /// Ordres TTS (hors changement de posture) joués espacés pendant le break + /// (« bois une gorgée », « respire à fond »…). + final List orders; + + const ScriptedBreak({ + required this.time, + required this.durationSeconds, + this.newPose, + this.orders = const [], + }); + + /// Seconde de fin du break (exclusive de la fenêtre d'effort suivante). + int get endTime => time + durationSeconds; +} diff --git a/rhythm_coach/lib/models/session.dart b/rhythm_coach/lib/models/session.dart index 9a54dea..38c20be 100644 --- a/rhythm_coach/lib/models/session.dart +++ b/rhythm_coach/lib/models/session.dart @@ -1,5 +1,6 @@ import '../career/models/challenge.dart'; import 'final_category.dart'; +import 'posture.dart'; import 'session_step.dart'; /// Mode global de la session. Peut être surchargé par étape. @@ -169,6 +170,17 @@ class Session { int? get challengeTriggerTime => challengeTriggerTimes.isEmpty ? null : challengeTriggerTimes.first; + /// Posture imposée au démarrage de la session (annoncée à l'intro). Défaut + /// [Posture.free] (« confort, au choix ») = aucune contrainte. Transient : + /// non sérialisé (généré par le générateur de carrière). Cf. spec locale + /// `specs/scripted_breaks.md`. + final Posture initialPose; + + /// Breaks scénarisés (pauses actives) insérés sur les sessions longues. + /// Liste ordonnée par temps d'entrée ; vide = aucun break (cas par défaut, + /// sessions courtes / hors carrière). Transient comme [challenges]. + final List breaks; + const Session({ required this.id, required this.name, @@ -193,6 +205,8 @@ class Session { this.noStats = false, this.challenges = const [], this.challengeTriggerTimes = const [], + this.initialPose = Posture.free, + this.breaks = const [], }); Duration get duration => Duration(seconds: durationSeconds); diff --git a/rhythm_coach/test/posture_model_test.dart b/rhythm_coach/test/posture_model_test.dart new file mode 100644 index 0000000..bbe2e75 --- /dev/null +++ b/rhythm_coach/test/posture_model_test.dart @@ -0,0 +1,98 @@ +import 'package:beat_bitch/models/posture.dart'; +import 'package:beat_bitch/models/session.dart'; +import 'package:beat_bitch/models/session_step.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Posture', () { + test('free n\'a pas de unlockKey, les autres oui', () { + expect(Posture.free.unlockKey, isNull); + for (final p in Posture.values.where((p) => p != Posture.free)) { + expect(p.unlockKey, isNotNull, reason: '$p doit avoir une unlockKey'); + } + }); + + test('unlockKeys uniques entre postures', () { + final keys = + Posture.values.map((p) => p.unlockKey).whereType().toList(); + expect(keys.toSet().length, keys.length); + }); + + test('serialized round-trip via fromString', () { + for (final p in Posture.values) { + expect(Posture.fromString(p.serialized), p); + } + }); + + test('fromString tolère null, casse et alias', () { + expect(Posture.fromString(null), Posture.free); + expect(Posture.fromString('inconnu'), Posture.free); + expect(Posture.fromString('KNEELING'), Posture.kneeling); + expect(Posture.fromString('allfours'), Posture.allFours); + expect(Posture.fromString('all_fours'), Posture.allFours); + expect(Posture.fromString('onback'), Posture.onBack); + expect(Posture.fromString('on_back'), Posture.onBack); + }); + + test('serialized des postures composées est snake_case', () { + expect(Posture.allFours.serialized, 'all_fours'); + expect(Posture.onBack.serialized, 'on_back'); + expect(Posture.kneeling.serialized, 'kneeling'); + }); + }); + + group('ScriptedBreak', () { + test('endTime = time + durationSeconds', () { + const b = ScriptedBreak(time: 600, durationSeconds: 90); + expect(b.endTime, 690); + }); + + test('défauts : pas de newPose, orders vide', () { + const b = ScriptedBreak(time: 0, durationSeconds: 60); + expect(b.newPose, isNull); + expect(b.orders, isEmpty); + }); + }); + + group('Session — champs posture', () { + Session make({Posture? pose, List? breaks}) => Session( + id: 's', + name: 'n', + description: '', + durationSeconds: 60, + defaultMode: SessionMode.rhythm, + steps: const [SessionStep(time: 0, from: Position.tip, bpm: 80)], + initialPose: pose ?? Posture.free, + breaks: breaks ?? const [], + ); + + test('défauts : free + pas de breaks', () { + final s = make(); + expect(s.initialPose, Posture.free); + expect(s.breaks, isEmpty); + }); + + test('porte la posture initiale et les breaks fournis', () { + final s = make( + pose: Posture.kneeling, + breaks: const [ + ScriptedBreak( + time: 300, durationSeconds: 90, newPose: Posture.allFours), + ], + ); + expect(s.initialPose, Posture.kneeling); + expect(s.breaks, hasLength(1)); + expect(s.breaks.first.newPose, Posture.allFours); + }); + + test('breaks/posture sont transients : absents de toJson', () { + final s = make( + pose: Posture.kneeling, + breaks: const [ScriptedBreak(time: 300, durationSeconds: 90)], + ); + final json = s.toJson(); + expect(json.containsKey('breaks'), isFalse); + expect(json.containsKey('initial_pose'), isFalse); + }); + }); +}