Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions rhythm_coach/lib/models/posture.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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;
}
14 changes: 14 additions & 0 deletions rhythm_coach/lib/models/session.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<ScriptedBreak> breaks;

const Session({
required this.id,
required this.name,
Expand All @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions rhythm_coach/test/posture_model_test.dart
Original file line number Diff line number Diff line change
@@ -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<String>().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<ScriptedBreak>? 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);
});
});
}
Loading