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.2 (Profil → bas de page) -->
- **Version BeatBitch** : <!-- ex: 0.5.3 (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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

## [Non publié]

## [0.5.3] — 2026-05-31

Correctif de progression carrière : le tutoriel pouvait revenir alors qu'il était déjà fait.

### Corrigé
- **Le tutoriel d'introduction revenait à la séance suivante** — quand un défi validait une compétence en cours de première séance (ex. tenir la gorge), il consolidait les paliers qui en dépendent mais pas le palier d'introduction lui-même, resté « à faire ». Résultat : la séance d'après reproposait le tutoriel déjà accompli, sans jamais pouvoir s'en débarrasser. Les paliers-prérequis sont désormais consolidés avec leurs enfants, et un rattrapage au démarrage répare les profils déjà touchés (sans remise à zéro).

## [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.
Expand Down Expand Up @@ -187,7 +194,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.2...develop
[Non publié]: https://github.com/bbstudioapp/beatbitch/compare/v0.5.3...develop
[0.5.3]: https://github.com/bbstudioapp/beatbitch/releases/tag/v0.5.3
[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
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.2-orange)
![version](https://img.shields.io/badge/version-0.5.3-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.2-orange)
![version](https://img.shields.io/badge/version-0.5.3-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.2-orange)
![version](https://img.shields.io/badge/version-0.5.3-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.2-orange)
![version](https://img.shields.io/badge/version-0.5.3-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.2-orange)
![version](https://img.shields.io/badge/version-0.5.3-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.2-orange)
![version](https://img.shields.io/badge/version-0.5.3-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
8 changes: 8 additions & 0 deletions rhythm_coach/lib/career/screens/career_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ class _CareerScreenState extends State<CareerScreen> {
// d'une capacité prouvée si la joueuse n'a pas atteint le palier.
final synthLevel =
CareerDifficultyResolver.synthLevelFor(completedSessions);
// Réparation à froid : consolide les milestones-parents dont un enfant
// déjà complété dépend mais qui sont restées « à faire » (acquittement
// par défi sur un unlock provisoire non consolidé — cf.
// `MilestoneService.consolidatePrerequisites`). Sans ça, le tutoriel
// `intro_basics` est ré-inséré tant qu'il n'est pas consolidé alors que
// ses enfants sont faits. Avant le reconcile : back-filler `basics`
// permet ensuite à `reconcileFromCapability` d'acquitter correctement.
await milestoneService.consolidatePrerequisites();
// Rattrapage à froid : acquitte les milestones que le profil de
// capacités prouve déjà (cas typique : la cascade transitive du défi
// a été livrée après que la joueuse l'ait joué — sans rattrapage,
Expand Down
50 changes: 50 additions & 0 deletions rhythm_coach/lib/career/services/milestone_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,56 @@ class MilestoneService extends ChangeNotifier {
}
}

/// Consolide les milestones-**parents** dont l'unlock conditionne une
/// milestone déjà complétée. Répare l'incohérence « enfant acquitté mais
/// parent jamais consolidé » : l'acquittement par défi
/// ([markCompletedViaChallenge]) peut valider un enfant
/// (ex. `intro_hold_throat`, `requires: [basics]`) en s'appuyant sur un
/// unlock **provisoire** de la séance (`basics`, fourni par `intro_basics`
/// insérée mais pas encore consolidée — cf. [effectiveUnlockKeysForChallenge]).
/// Si le parent n'est jamais consolidé (séance non terminée proprement,
/// fail…), il reste candidat et est ré-inséré aux séances suivantes alors
/// que ses enfants sont faits — le tutoriel « revient ».
///
/// Algorithme : pour chaque milestone complétée, tout `requires` non
/// couvert par un unlock déjà acquis lifetime ([acquiredUnlockKeys]) est
/// comblé en consolidant la/les milestone(s) du catalogue qui l'accordent
/// (invariant 1 milestone → 1 unlock, mais on traite N par robustesse).
/// Itère jusqu'à point fixe (un parent peut lui-même avoir des `requires`).
/// Idempotent ; persiste + notifie une seule fois si quelque chose a bougé.
/// Retourne le nombre de parents consolidés.
Future<int> consolidatePrerequisites() async {
if (!_loaded) return 0;
final added = <String>{};
var changed = true;
while (changed) {
changed = false;
final granted = acquiredUnlockKeys();
for (final completedId in _completed.toList()) {
final m = findById(completedId);
if (m == null) continue;
for (final req in m.requires) {
if (granted.contains(req)) continue;
for (final parent in _catalog) {
if (_completed.contains(parent.id)) continue;
if (!parent.unlocks.contains(req)) continue;
_completed.add(parent.id);
added.add(parent.id);
changed = true;
}
}
}
}
if (added.isEmpty) return 0;
await _persist();
for (final id in added) {
await resetRetryCount(id);
await resetCandidacyAge(id);
}
notifyListeners();
return added.length;
}

/// Rattrapage à froid : acquitte les milestones que le [profile] de
/// capacités prouve **déjà**, indépendamment d'un défi récent. Réutilise
/// `milestonesAcquittableByChallenge` sur chaque axe du profil qui a une
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,15 @@ extension ChallengeOrchestrator on SessionController {
for (final m in acquittable) {
await milestoneService.markCompletedViaChallenge(m.id);
}
// L'acquittement ci-dessus a pu s'appuyer sur un unlock **provisoire**
// de la séance (cf. `effectiveUnlockKeysForChallenge`) — typiquement
// `basics`, fourni par `intro_basics` insérée mais pas encore
// consolidée. On consolide donc ses parents pour ne pas laisser des
// enfants acquittés sans leur prérequis (sinon le tutoriel est
// ré-inséré aux séances suivantes). No-op si rien à combler.
if (acquittable.isNotEmpty) {
await milestoneService.consolidatePrerequisites();
}
}

/// Phase finale défis — consume la tête de la file showcase si la
Expand Down
2 changes: 1 addition & 1 deletion rhythm_coach/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: beat_bitch
description: An offline rhythmic voice coach for Android.
publish_to: 'none'
version: 0.5.2+13
version: 0.5.3+14

environment:
sdk: '>=3.3.0 <4.0.0'
Expand Down
131 changes: 131 additions & 0 deletions rhythm_coach/test/milestone_consolidate_prereq_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import 'package:beat_bitch/career/models/level_milestone.dart';
import 'package:beat_bitch/career/models/unlock_key.dart';
import 'package:beat_bitch/career/services/milestone_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

LevelMilestone _milestone({
required String id,
List<UnlockKey> unlocks = const [],
List<UnlockKey> requires = const [],
}) {
return LevelMilestone(
id: id,
humilRequired: 0,
displayLabel: id,
sequence: const [],
durationSeconds: 1,
unlocks: unlocks,
requires: requires,
minLevel: 1,
);
}

void main() {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues(<String, Object>{});
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
});

tearDownAll(() {
debugDefaultTargetPlatformOverride = null;
});

group('consolidatePrerequisites', () {
test('enfants complétés sans le parent (intro_basics) → parent consolidé',
() async {
// Reproduit le bug : un défi tuto acquitte `intro_hold_throat` /
// `intro_final_hold_head` (requires `basics`) via l'unlock provisoire
// de `intro_basics` insérée mais jamais consolidée. État persisté =
// enfants faits, parent en attente → tutoriel ré-inséré.
final basics =
_milestone(id: 'intro_basics', unlocks: [UnlockKey.basics]);
final throat = _milestone(
id: 'intro_hold_throat',
unlocks: [UnlockKey.throatHold],
requires: [UnlockKey.basics],
);
final finalHead = _milestone(
id: 'intro_final_hold_head',
unlocks: [UnlockKey.finalHoldHead],
requires: [UnlockKey.basics],
);
final svc = MilestoneService();
svc.seedForTest(
catalog: [basics, throat, finalHead],
completed: {'intro_hold_throat', 'intro_final_hold_head'},
);

expect(svc.isCompleted('intro_basics'), isFalse);
final added = await svc.consolidatePrerequisites();

expect(added, 1);
expect(svc.isCompleted('intro_basics'), isTrue);
// Et donc plus jamais candidat à la ré-insertion : son unlock est acquis.
expect(svc.hasUnlock(UnlockKey.basics), isTrue);
});

test('idempotent : 2e appel ne consolide rien', () async {
final basics =
_milestone(id: 'intro_basics', unlocks: [UnlockKey.basics]);
final throat = _milestone(
id: 'intro_hold_throat',
unlocks: [UnlockKey.throatHold],
requires: [UnlockKey.basics],
);
final svc = MilestoneService();
svc.seedForTest(
catalog: [basics, throat],
completed: {'intro_hold_throat'},
);

expect(await svc.consolidatePrerequisites(), 1);
expect(await svc.consolidatePrerequisites(), 0);
});

test('chaîne transitive : point fixe consolide tous les ancêtres',
() async {
// grandParent → parent → child : seul `child` est complété.
final grandParent = _milestone(id: 'gp', unlocks: [UnlockKey.basics]);
final parent = _milestone(
id: 'p',
unlocks: [UnlockKey.rhythmMidBasic],
requires: [UnlockKey.basics],
);
final child = _milestone(
id: 'c',
unlocks: [UnlockKey.holdMid],
requires: [UnlockKey.rhythmMidBasic],
);
final svc = MilestoneService();
svc.seedForTest(
catalog: [grandParent, parent, child],
completed: {'c'},
);

final added = await svc.consolidatePrerequisites();
expect(added, 2);
expect(svc.isCompleted('p'), isTrue);
expect(svc.isCompleted('gp'), isTrue);
});

test('rien à réparer : set cohérent → 0', () async {
final basics =
_milestone(id: 'intro_basics', unlocks: [UnlockKey.basics]);
final throat = _milestone(
id: 'intro_hold_throat',
unlocks: [UnlockKey.throatHold],
requires: [UnlockKey.basics],
);
final svc = MilestoneService();
svc.seedForTest(
catalog: [basics, throat],
completed: {'intro_basics', 'intro_hold_throat'},
);

expect(await svc.consolidatePrerequisites(), 0);
});
});
}
Loading