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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ assignees: ''

## Environnement / Environment

- **Version BeatBitch** : <!-- ex: 0.5.1 (Profil → bas de page) -->
- **Version BeatBitch** : <!-- ex: 0.5.2 (Profil → bas de page) -->
- **Plateforme / Platform** : <!-- coche / check : Android | Windows desktop | Linux desktop | autre / other -->
- **OS** : <!-- ex: Android 15 / Samsung Galaxy S21 — ou — Windows 11 23H2 — ou — Ubuntu 24.04 -->
- **Langue dans l'app / App language** : <!-- FR, EN, DE -->
Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

## [Non publié]

## [0.5.2] — 2026-05-31

Correctifs ciblés sur les **défis intra-séance** (deux retours iOS) plus un nouveau geste d'input pour les défis dynamiques.

### Ajouté
- **Input « tap GO / STOP » pour les défis dynamiques** — les défis rythmés, de franchissement de gorge, de biffle et d'endurance ne demandent plus de garder le doigt collé à l'écran toute la durée (épuisant quand le geste entre en compétition avec l'acte). On tape **DÉMARRE** pour lancer, le défi tourne seul, et un bouton **STOP** plein écran sert à abandonner (en cours) ou à valider (au seuil). Les holds statiques (gorge, fond, apnée) gardent la tenue du doigt, qui leur reste naturelle.

### Corrigé
- **Défi de franchissement de gorge qui ne se terminait jamais** — sur les axes « franchissement gorge », le compteur de passages restait figé à 0 (position cible non définie côté contrôleur), donc le seuil de fin n'était jamais atteint et le défi s'enchaînait indéfiniment (deepthroat rapide sans fin visible). Position cible recâblée → le défi se conclut au nombre de franchissements annoncé.
- **Démarrage bloqué sur iOS** — après le décompte de mise en place, la séance pouvait rester coincée sur l'écran « PRÊT » sans bouton pour commencer (uniquement les sliders de volume). Un init non essentiel (wakelock / audio) qui échouait sur Safari/PWA interrompait `start()` avant le passage en lecture. Ces inits deviennent best-effort : la séance démarre toujours, au pire sans wakelock.

## [0.5.1] — 2026-05-26

Itération de calibration et de polish autour des **défis intra-séance** introduits en 0.5.0 : refonte mécanique des builders en mode streaming (un builder par axe), banners et verdicts plus lisibles, gating profondeur/amplitude resserré, et plusieurs correctifs de progression (acquittement, anti-répétition, redondances). Plus une montée d'intensité visible sur les Supplier / Encore et un calibrage du `throat_pulse`.
Expand Down Expand Up @@ -176,7 +187,8 @@ Grosse mise à jour du mode carrière : nouvelle enveloppe de difficulté, nouve
## [0.1.0] — 2026-05-08
- Premier release public : coach vocal rythmique hors-ligne pour Android, adult gate 18+, onboarding, mode carrière + scénarios, badges, profil/réputation.

[Non publié]: https://github.com/bbstudioapp/beatbitch/compare/v0.5.1...develop
[Non publié]: https://github.com/bbstudioapp/beatbitch/compare/v0.5.2...develop
[0.5.2]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.2
[0.5.1]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.1
[0.5.0]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.0
[0.4.2]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.4.2
Expand Down
2 changes: 1 addition & 1 deletion README.de.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BeatBitch

![version](https://img.shields.io/badge/version-0.5.1-orange)
![version](https://img.shields.io/badge/version-0.5.2-orange)
![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux%20%7C%20iOS%20%7C%20Web-blue)
![offline](https://img.shields.io/badge/100%25-offline-blue)
![no tracking](https://img.shields.io/badge/no-tracking-success)
Expand Down
2 changes: 1 addition & 1 deletion README.fr.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BeatBitch

![version](https://img.shields.io/badge/version-0.5.1-orange)
![version](https://img.shields.io/badge/version-0.5.2-orange)
![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux%20%7C%20iOS%20%7C%20Web-blue)
![offline](https://img.shields.io/badge/100%25-offline-blue)
![no tracking](https://img.shields.io/badge/no-tracking-success)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BeatBitch

![version](https://img.shields.io/badge/version-0.5.1-orange)
![version](https://img.shields.io/badge/version-0.5.2-orange)
![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux%20%7C%20iOS%20%7C%20Web-blue)
![offline](https://img.shields.io/badge/100%25-offline-blue)
![no tracking](https://img.shields.io/badge/no-tracking-success)
Expand Down
2 changes: 1 addition & 1 deletion rhythm_coach/README.de.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BeatBitch

![version](https://img.shields.io/badge/version-0.5.1-orange)
![version](https://img.shields.io/badge/version-0.5.2-orange)
![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux-blue)
![offline](https://img.shields.io/badge/100%25-offline-blue)

Expand Down
2 changes: 1 addition & 1 deletion rhythm_coach/README.fr.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BeatBitch

![version](https://img.shields.io/badge/version-0.5.1-orange)
![version](https://img.shields.io/badge/version-0.5.2-orange)
![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux-blue)
![offline](https://img.shields.io/badge/100%25-offline-blue)

Expand Down
2 changes: 1 addition & 1 deletion rhythm_coach/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BeatBitch

![version](https://img.shields.io/badge/version-0.5.1-orange)
![version](https://img.shields.io/badge/version-0.5.2-orange)
![platform](https://img.shields.io/badge/platform-Android%20%7C%20Windows%20%7C%20Linux-blue)
![offline](https://img.shields.io/badge/100%25-offline-blue)

Expand Down
29 changes: 29 additions & 0 deletions rhythm_coach/lib/career/models/challenge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ enum ChallengePhase {
ended,
}

/// Mode d'input live d'un défi — détermine le geste qui pilote la machine
/// d'états pendant `countdown`/`live`/`atSeuil`.
///
/// Dérivé du mode du step défi (cf. `Challenge.inputMode`), pas stocké : la
/// ligne de partage « statique → hold, dynamique → tap » coïncide exactement
/// avec « mode hold vs autre mode ».
enum ChallengeInputMode {
/// Le doigt reste présent sur l'écran toute la durée — geste congruent aux
/// holds statiques (la joueuse est déjà immobile). Le relâchement EST le
/// signal d'abandon (fail en `live`) ou de validation (succès en `atSeuil`).
/// C'est le mode historique.
hold,

/// Tap `GO` pour démarrer (après le countdown le défi tourne sur sa propre
/// horloge, sans présence du doigt), tap `STOP` plein largeur pour abandonner
/// en `live` ou valider en `atSeuil`. Pour les défis dynamiques/longs
/// (rythme, franchissement, biffle, endurance) où pinner le doigt entre en
/// compétition avec l'acte physique.
tapToggle,
}

/// Défi intra-séance immuable, généré par `ChallengeService` à partir du
/// profil de capacités et figé pour toute la durée de la séance. Le
/// `CareerSessionGenerator` consomme la calibration (mode, durée, BPM…)
Expand Down Expand Up @@ -218,6 +239,14 @@ class Challenge {
/// (cf. `Coach.pickChallengePhrase`).
String get axisStorageKey => axis.storageKey;

/// Mode d'input live (cf. [ChallengeInputMode]). Dérivé du [mode] : les
/// holds statiques gardent la tenue du doigt ; tout le reste (rythme,
/// biffle, franchissement, endurance) passe en tap `GO`/`STOP`, où la tenue
/// continue serait ergonomiquement coûteuse pendant un acte rapide.
ChallengeInputMode get inputMode => mode == SessionMode.hold
? ChallengeInputMode.hold
: ChallengeInputMode.tapToggle;

/// Durée d'une prolongation « tient encore » en mode ouvert.
/// Plancher 10 s, sinon `comfort × 0.30`. En exploratoire (`comfort`
/// inconnu), on fallback sur le plancher 10 s (pas de proportion à
Expand Down
18 changes: 14 additions & 4 deletions rhythm_coach/lib/career/services/challenge_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -709,13 +709,17 @@ class ChallengeService {
// Rhythm shallow : head→mid (amplitude légère, bande `to ≤ mid`).
case CapabilityAxis.rhythmBpmCeilShallow:
return Position.head;
// Rhythm throat : head→throat (franchissement gorge à vitesse
// calibrée — l'axe mesure le BPM en bande throat).
// Rhythm throat / franchissement gorge throat : head→throat
// (franchissement gorge à vitesse calibrée). Le compteur de
// franchissements côté contrôleur exige un `to` non-null (cf. `_toOf`).
case CapabilityAxis.rhythmBpmCeilThroat:
case CapabilityAxis.gorgeCrossingsBpmThroat:
return Position.head;
// Rhythm full : mid→full (franchissement profond, le `from` ne
// peut pas être head pour rester réaliste à BPM élevé).
// Rhythm full / franchissement gorge full : mid→full (franchissement
// profond, le `from` ne peut pas être head pour rester réaliste à
// BPM élevé).
case CapabilityAxis.rhythmBpmCeilFull:
case CapabilityAxis.gorgeCrossingsBpmFull:
return Position.mid;
case CapabilityAxis.rhythmMotionStreak:
case CapabilityAxis.rhythmDepthMax:
Expand All @@ -735,10 +739,16 @@ class ChallengeService {
switch (axis) {
case CapabilityAxis.rhythmBpmCeilShallow:
return Position.mid;
// `to` = position de comptage des franchissements. Indispensable non-null
// pour les axes `kCrossingsChallengeAxes` : `_onChallengeBeatIfCrossingsTracked`
// n'incrémente `_challengeCrossingsCount` que sur `e.to == ch.to`, donc un
// `to` null fige le compteur à 0 et le défi ne se termine jamais.
case CapabilityAxis.rhythmBpmCeilThroat:
case CapabilityAxis.gorgeCrossingsBpmThroat:
case CapabilityAxis.rhythmMotionStreak:
return Position.throat;
case CapabilityAxis.rhythmBpmCeilFull:
case CapabilityAxis.gorgeCrossingsBpmFull:
return Position.full;
case CapabilityAxis.rhythmDepthMax:
case CapabilityAxis.effortNoBreathStreak:
Expand Down
29 changes: 26 additions & 3 deletions rhythm_coach/lib/controllers/session_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -940,9 +940,32 @@ class SessionController extends ChangeNotifier {
_stats.getObedienceLevel().then(_obedience.seed);
}

await _tts.init();
await _beep.init();
await WakelockPlus.enable();
// Inits best-effort : aucun de ces appels n'est un prérequis du passage
// en `running`. Un échec (typiquement iOS Safari/PWA — voir ci-dessous)
// ne doit JAMAIS avorter `start()` : sinon `_state` reste `idle` et,
// en prod, l'écran de jeu n'a aucun bouton play (gated derrière le
// toggle debug `showSessionControls`) → soft-lock total (cf. retour
// iOS « pas de bouton pour commencer », v0.4.0).
try {
await _tts.init();
} catch (e) {
debugPrint('start(): _tts.init() a échoué (non bloquant) : $e');
}
try {
await _beep.init();
} catch (e) {
debugPrint('start(): _beep.init() a échoué (non bloquant) : $e');
}
try {
// Wakelock = garder l'écran allumé (confort, non essentiel). Sur iOS
// Safari/PWA la Wake Lock API exige un contexte de geste utilisateur ;
// appelée depuis le Timer de prep (7 s après « JE SUIS PRÊTE »), elle
// peut lever `NotAllowedError`.
await WakelockPlus.enable();
} catch (e) {
debugPrint(
'start(): WakelockPlus.enable() a échoué (non bloquant) : $e');
}

// Reset du fond média : on repart sur le placeholder animé tant que
// le premier step de config n'a pas tiré une entrée. Évite qu'une
Expand Down
76 changes: 64 additions & 12 deletions rhythm_coach/lib/controllers/session_controller_challenge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ extension ChallengeOrchestrator on SessionController {
_challengePhase != ChallengePhase.none &&
_challengePhase != ChallengePhase.ended;

/// `true` si le défi actif consomme l'input « doigt présent » (mode hold).
/// `false` pour un défi en mode tap (`GO`/`STOP` explicites) : l'UI ne doit
/// alors PAS router la présence du doigt vers `onChallengeHoldStart/End`
/// (sinon un tap accidentel n'importe où démarrerait/arrêterait le défi).
bool get challengeUsesHoldInput =>
isChallengeActive &&
(_activeChallenge?.inputMode ?? ChallengeInputMode.hold) ==
ChallengeInputMode.hold;

bool get _inPostChallengeBreath {
final until = _postChallengeBreathRealEndSec;
return until != null && _realSec.toInt() < until;
Expand Down Expand Up @@ -152,13 +161,19 @@ extension ChallengeOrchestrator on SessionController {
if (l10n == null) return null;
switch (tier) {
case 'attempt':
// Tutoriel hold throat = annonce dédiée plus pédagogique.
// Tutoriel hold throat = annonce dédiée plus pédagogique (toujours
// en mode hold).
if (ch.isTutorial && ch.axis == CapabilityAxis.holdThroatStreak) {
return l10n.challengeAttemptTutorialHoldThroat;
}
return l10n.challengeAttemptDefault;
// Mode tap : instruction GO/STOP, pas « garde le doigt ».
return ch.inputMode == ChallengeInputMode.tapToggle
? l10n.challengeAttemptTapDefault
: l10n.challengeAttemptDefault;
case 'extension':
return l10n.challengeExtensionDefault;
return ch.inputMode == ChallengeInputMode.tapToggle
? l10n.challengeExtensionTapDefault
: l10n.challengeExtensionDefault;
case 'success':
return l10n.challengeSuccessDefault;
case 'stop':
Expand Down Expand Up @@ -465,14 +480,35 @@ extension ChallengeOrchestrator on SessionController {
return true;
}
if (phase == ChallengePhase.live) {
_capabilityTracker?.onFail();
_completeChallenge(ChallengeOutcome.fail);
_emitChallengeHaptic(_ChallengeHapticKind.heavy);
_failChallengeLive();
return true;
}
return false;
}

/// Abandon d'un défi en cours (phase `live`) : tap-out avant le seuil.
/// Partagé entre la perte du doigt (mode hold, via la tolérance de release)
/// et le bouton `STOP` (mode tap). Pose un FAIL capability + haptic lourd.
void _failChallengeLive() {
_capabilityTracker?.onFail();
_completeChallenge(ChallengeOutcome.fail);
_emitChallengeHaptic(_ChallengeHapticKind.heavy);
}

/// Validation d'un défi au seuil (phase `atSeuil`) : succès net, ou étendu
/// selon les extensions accumulées en laissant tourner au-delà du seuil.
/// Partagé entre le relâchement du doigt (mode hold) et le bouton `STOP`
/// (mode tap) — dans les deux cas, le nombre d'extensions est dérivé de la
/// durée tenue depuis l'entrée en `atSeuil` (cf. `_deriveChallengeExtensionsCount`).
void _bankChallengeFromAtSeuil() {
_challengeReleaseAtRealSec = null;
_challengeExtensionsCount = _deriveChallengeExtensionsCount();
final outcome = _challengeExtensionsCount > 0
? ChallengeOutcome.extendedSuccess
: ChallengeOutcome.netSuccess;
_completeChallenge(outcome);
}

void _emitChallengeHaptic(_ChallengeHapticKind kind) {
final cb = onChallengeHaptic;
if (cb == null) return;
Expand Down Expand Up @@ -530,12 +566,7 @@ extension ChallengeOrchestrator on SessionController {
_challengeHoldActive = false;
final phase = _challengePhase;
if (phase == ChallengePhase.atSeuil) {
_challengeReleaseAtRealSec = null;
_challengeExtensionsCount = _deriveChallengeExtensionsCount();
final outcome = _challengeExtensionsCount > 0
? ChallengeOutcome.extendedSuccess
: ChallengeOutcome.netSuccess;
_completeChallenge(outcome);
_bankChallengeFromAtSeuil();
return;
}
if (phase == ChallengePhase.countdown || phase == ChallengePhase.live) {
Expand All @@ -545,6 +576,27 @@ extension ChallengeOrchestrator on SessionController {
}
}

/// Bouton `STOP` des défis en mode tap (`ChallengeInputMode.tapToggle`).
/// Le démarrage (`GO`) passe par `onChallengeHoldStart` comme en mode hold —
/// seule la SORTIE diffère : ici un tap explicite remplace le relâchement du
/// doigt.
/// - `live` : abandon (tap-out avant le seuil) → fail.
/// - `atSeuil` : validation → succès net ou étendu selon les extensions
/// accumulées en laissant tourner.
/// - autres phases (`breath`/`countdown`/`none`/`ended`) : no-op (le
/// `breath` a `PASSE`, le `countdown` court tout seul en mode tap).
void onChallengeTapStop() {
final phase = _challengePhase;
if (phase == ChallengePhase.atSeuil) {
_bankChallengeFromAtSeuil();
return;
}
if (phase == ChallengePhase.live) {
_failChallengeLive();
return;
}
}

/// Calcule le compteur d'extensions à appliquer au release après seuil.
/// = `floor((releaseAtRealSec − atSeuilEnteredAtRealSec) ÷ extensionSeconds)`.
/// Plancher 0 (release immédiate au seuil = pas d'extension).
Expand Down
6 changes: 6 additions & 0 deletions rhythm_coach/lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"challengeHoldHintLive": "Lass deinen Finger auf dem Bildschirm",
"challengeHoldHintAtSeuil": "Loslassen zum Beenden, oder weiterhalten",
"challengeHoldHintTolerance": "Leg deinen Finger zurück",
"challengeTapGoButton": "START",
"challengeTapStopLive": "AUFGEBEN",
"challengeTapStopAtSeuil": "FERTIG",
"challengeTapCountdownHint": "Mach dich bereit…",
"challengeCountdownReleaseRetry": "Halte deinen Finger diesmal.",
"challengeBannerCountdown": "{digit}",
"@challengeBannerCountdown": {
Expand All @@ -256,6 +260,8 @@
"challengeAttemptDefault": "Herausforderung: Wir gehen an deine Grenze. Drücke und halte HALTEN wenn du bereit bist, ich zähle drei zwei eins vor dem Start. Lass deinen Finger auf dem Bildschirm, solange du hältst.",
"challengeAttemptTutorialHoldThroat": "Erste Herausforderung: Du hältst tief fünf Sekunden. Drücke und halte HALTEN, lass deinen Finger auf dem Bildschirm. Wenn du vor dem Schwellenwert loslässt, ist es gescheitert. Am Schwellenwert wird die Taste grün: loslassen zum Beenden, oder weiterhalten um weiter zu pushen.",
"challengeExtensionDefault": "Du kannst länger bleiben, wenn du willst, oder loslassen.",
"challengeAttemptTapDefault": "Herausforderung: Wir gehen an deine Grenze. Tippe START wenn du bereit bist, ich zähle drei zwei eins. Dann folge dem Rhythmus — tippe AUFGEBEN, wenn du schlappmachst. Am Schwellenwert tippe FERTIG zum Bestätigen, oder mach weiter um weiter zu pushen.",
"challengeExtensionTapDefault": "Du kannst weitermachen, wenn du willst, oder tippe FERTIG.",
"challengeSuccessDefault": "Du hast bis zum Ende gehalten. Braves Mädchen.",
"challengeStopDefault": "Du hast bis zum Schwellenwert gehalten. Gut gemacht.",
"challengeFailDefault": "Du hast vor dem Schwellenwert nachgegeben. Kein Problem, beim nächsten Mal schaffst du es.",
Expand Down
6 changes: 6 additions & 0 deletions rhythm_coach/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"challengeHoldHintLive": "Keep your finger on the screen",
"challengeHoldHintAtSeuil": "Release to stop, or keep holding",
"challengeHoldHintTolerance": "Put your finger back",
"challengeTapGoButton": "START",
"challengeTapStopLive": "GIVE UP",
"challengeTapStopAtSeuil": "I'M DONE",
"challengeTapCountdownHint": "Get ready…",
"challengeCountdownReleaseRetry": "Keep your finger this time.",
"challengeBannerCountdown": "{digit}",
"@challengeBannerCountdown": {
Expand All @@ -256,6 +260,8 @@
"challengeAttemptDefault": "Challenge: we're pushing your limit. Press and hold HOLD when ready, I'll count three two one before we start. Keep your finger on the screen as long as you hold.",
"challengeAttemptTutorialHoldThroat": "First challenge: you'll hold deep for five seconds. Press and hold HOLD, keep your finger on the screen. If you let go before the threshold, it's failed. At the threshold the button turns green: release to stop, or keep holding to push further.",
"challengeExtensionDefault": "You can stay longer if you want, or let go.",
"challengeAttemptTapDefault": "Challenge: we're pushing your limit. Tap START when you're ready, I'll count three two one. Then follow the rhythm — tap GIVE UP if you crack. At the threshold, tap I'M DONE to lock it in, or keep going to push further.",
"challengeExtensionTapDefault": "You can keep going if you want, or tap I'M DONE.",
"challengeSuccessDefault": "You held it all the way. Good girl.",
"challengeStopDefault": "You held to the threshold. Well done.",
"challengeFailDefault": "You broke before the threshold. No worries, you'll get it next time.",
Expand Down
Loading
Loading