feat(j0.3): socle transverse (Result/Failure, theme, router, DI)
- core/error : Result<S> maison (Ok/Err) + Failure scellee avec egalite de valeur - core/theme : AppTheme Material 3 (palette coucher, cibles tactiles enfant) - core/router : routes nommees child/parentGate/parent (Navigator 1, placeholders) - core/di : conventions providers - CLAUDE.md §7 : Result maison & Navigator 1 actes (YAGNI) - ROADMAP : 0.3 cochee, Jalon 0 termine - corrections code review : egalite Failure, assertions tests, Map.unmodifiable Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,11 +140,13 @@ Feature-first, puis découpage en couches dans chaque feature. **Un fichier = un
|
||||
|-------|-------|--------|
|
||||
| Langage/UI | Flutter / Material 3 | Mono-plateforme Android, connu de l'auteur |
|
||||
| État + DI | Riverpod | Testable, override facile en test |
|
||||
| Gestion d'erreur | `Result<S>` maison (`Ok`/`Err`) — PAS de fpdart/dartz | YAGNI : on n'a besoin que de `map`/`fold`/`when` ; pas de dépendance externe, contrôle total |
|
||||
| Audio | `just_audio` + `audio_service` | Lecture + contrôle arrière-plan |
|
||||
| Épinglage | `kiosk_mode` (plugin), fallback platform channel Kotlin | API native Screen Pinning |
|
||||
| Recherche podcasts | API iTunes Search (gratuite, sans clé) | Annuaire public |
|
||||
| RSS | `dart_rss` (ou `webfeed`) | Parsing de flux |
|
||||
| Persistance | `sqflite`/`drift` (abonnements) + `flutter_secure_storage` (code haché) | Local, sûr |
|
||||
| Lecture seule réglages | `shared_preferences` | Compteurs/limites |
|
||||
| Router | Navigator 1 + routes nommées — PAS de go_router | YAGNI : pas de deep-linking ni de navigation imbriquée en v1 |
|
||||
|
||||
Tout changement de cette table doit être justifié dans le commit et reflété ici.
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ Spec détaillée : [`docs/specs/`](docs/specs/) — un dossier par jalon, étape
|
||||
|
||||
| Jalon | Titre | Statut | Bloquant |
|
||||
|-------|-------|--------|----------|
|
||||
| 0 | Fondations | `[~]` en cours | — |
|
||||
| 0 | Fondations | `[x]` terminé (2026-06-19) | — |
|
||||
| 1 | Verrouillage / épinglage | `[ ]` à faire | ⚠️ **bloquant projet** |
|
||||
| 2 | Lecture audio | `[ ]` à faire | — |
|
||||
| 3 | Découverte & gestion des podcasts | `[ ]` à faire | — |
|
||||
@@ -31,7 +31,7 @@ Spec : [`docs/specs/jalon-0-fondations/`](docs/specs/jalon-0-fondations/)
|
||||
|
||||
- [x] 0.1 — Structure du projet Flutter & arborescence clean archi (2026-06-19)
|
||||
- [x] 0.2 — Outillage qualité (lint strict, format, CI locale) (2026-06-19)
|
||||
- [ ] 0.3 — Socle transverse (`Result`, erreurs, thème, router, DI Riverpod)
|
||||
- [x] 0.3 — Socle transverse (`Result`, erreurs, thème, router, DI Riverpod) (2026-06-19)
|
||||
|
||||
## Jalon 1 — Verrouillage / épinglage ⚠️
|
||||
Spec : [`docs/specs/jalon-1-verrouillage/`](docs/specs/jalon-1-verrouillage/)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Providers transverses (core).
|
||||
//
|
||||
// Convention : chaque feature expose ses propres providers dans
|
||||
// `lib/features/<feature>/presentation/providers.dart`.
|
||||
// Ce fichier agrège les providers du core ; il restera vide tant qu'aucun
|
||||
// provider transverse n'est nécessaire.
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Types d'échec métier du domaine.
|
||||
///
|
||||
/// Les features ajoutent leurs propres sous-types en étendant [Failure].
|
||||
/// Les exceptions techniques (réseau, IO) sont capturées dans la couche data
|
||||
/// et converties en [Failure] avant de remonter.
|
||||
sealed class Failure {
|
||||
const Failure(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Failure &&
|
||||
other.runtimeType == runtimeType &&
|
||||
other.message == message;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, message);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType($message)';
|
||||
}
|
||||
|
||||
/// Erreur de communication réseau (timeout, pas de connectivité, HTTP 5xx…).
|
||||
final class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message);
|
||||
}
|
||||
|
||||
/// Ressource demandée introuvable (HTTP 404, entité absente en base…).
|
||||
final class NotFoundFailure extends Failure {
|
||||
const NotFoundFailure(super.message);
|
||||
}
|
||||
|
||||
/// Erreur inattendue non catégorisée — à affiner si elle se répète.
|
||||
final class UnexpectedFailure extends Failure {
|
||||
const UnexpectedFailure(super.message);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:storytime/core/error/failure.dart';
|
||||
|
||||
/// Résultat d'une opération qui peut échouer.
|
||||
///
|
||||
/// - [Ok] : succès avec une valeur de type [S].
|
||||
/// - [Err] : échec avec un [Failure] typé.
|
||||
///
|
||||
/// Utilisé par les use cases pour renvoyer succès ou erreur sans lever
|
||||
/// d'exception à travers les couches.
|
||||
sealed class Result<S> {
|
||||
const Result();
|
||||
|
||||
/// Transforme la valeur en cas de succès ; laisse [Err] intact.
|
||||
Result<T> map<T>(T Function(S value) transform) => switch (this) {
|
||||
Ok<S>(:final value) => Ok<T>(transform(value)),
|
||||
Err<S>(:final failure) => Err<T>(failure),
|
||||
};
|
||||
|
||||
/// Réduit le résultat à une valeur unique en fournissant les deux branches.
|
||||
T fold<T>({
|
||||
required T Function(S value) onOk,
|
||||
required T Function(Failure failure) onErr,
|
||||
}) => switch (this) {
|
||||
Ok<S>(:final value) => onOk(value),
|
||||
Err<S>(:final failure) => onErr(failure),
|
||||
};
|
||||
|
||||
/// Exécute un effet de bord selon la branche, sans retourner de valeur.
|
||||
void when({
|
||||
required void Function(S value) ok,
|
||||
required void Function(Failure failure) err,
|
||||
}) {
|
||||
switch (this) {
|
||||
case Ok<S>(:final value):
|
||||
ok(value);
|
||||
case Err<S>(:final failure):
|
||||
err(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cas succès.
|
||||
final class Ok<S> extends Result<S> {
|
||||
const Ok(this.value);
|
||||
|
||||
final S value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Ok<S> && other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'Ok($value)';
|
||||
}
|
||||
|
||||
/// Cas échec.
|
||||
final class Err<S> extends Result<S> {
|
||||
const Err(this.failure);
|
||||
|
||||
final Failure failure;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Err<S> && other.failure == failure;
|
||||
|
||||
@override
|
||||
int get hashCode => failure.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'Err($failure)';
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Routes nommées de l'application.
|
||||
///
|
||||
/// Pas de `go_router` pour l'instant — Navigator simple suffit (YAGNI).
|
||||
/// Les écrans réels seront branchés quand les features correspondantes
|
||||
/// seront implémentées.
|
||||
abstract final class AppRoutes {
|
||||
/// Espace enfant — route par défaut au démarrage.
|
||||
static const String child = '/';
|
||||
|
||||
/// Écran de saisie du code parental (accès à l'espace parent).
|
||||
static const String parentGate = '/parent-gate';
|
||||
|
||||
/// Espace parent (gestion podcasts, limites, réglages).
|
||||
static const String parent = '/parent';
|
||||
}
|
||||
|
||||
/// Table de routage de l'application.
|
||||
///
|
||||
/// À enrichir au fur et à mesure que les features sont implémentées.
|
||||
final Map<String, WidgetBuilder> appRoutes = Map.unmodifiable({
|
||||
AppRoutes.child: (_) => const _ChildPlaceholder(),
|
||||
AppRoutes.parentGate: (_) => const _ParentGatePlaceholder(),
|
||||
AppRoutes.parent: (_) => const _ParentPlaceholder(),
|
||||
});
|
||||
|
||||
class _ChildPlaceholder extends StatelessWidget {
|
||||
const _ChildPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Scaffold(body: Center(child: Text('Storytime')));
|
||||
}
|
||||
|
||||
class _ParentGatePlaceholder extends StatelessWidget {
|
||||
const _ParentGatePlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Scaffold(body: Center(child: Text('Code parental')));
|
||||
}
|
||||
|
||||
class _ParentPlaceholder extends StatelessWidget {
|
||||
const _ParentPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Scaffold(body: Center(child: Text('Espace parent')));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Thème de l'application Storytime.
|
||||
///
|
||||
/// Palette « coucher » : violet/indigo doux, faible luminosité, contrastes
|
||||
/// lisibles dans la pénombre. Composants tactiles surdimensionnés (cible enfant).
|
||||
abstract final class AppTheme {
|
||||
// Couleur de seed : indigo doux, hue ~250 — dans le spectre bleu-violet.
|
||||
static const Color _seed = Color(0xFF5C6BC0); // indigo 400
|
||||
|
||||
/// Thème clair (utilisé en mode normal ; le mode sombre viendra si besoin).
|
||||
static ThemeData light() => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
// Boutons larges pour les doigts d'enfant.
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(64, 56)),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(64, 56)),
|
||||
),
|
||||
);
|
||||
}
|
||||
+6
-11
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:storytime/core/router/app_router.dart';
|
||||
import 'package:storytime/core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: StorytimeApp()));
|
||||
@@ -9,17 +11,10 @@ class StorytimeApp extends StatelessWidget {
|
||||
const StorytimeApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
title: 'Storytime',
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Storytime',
|
||||
style: Theme.of(context).textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: AppTheme.light(),
|
||||
initialRoute: AppRoutes.child,
|
||||
routes: appRoutes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:storytime/core/error/failure.dart';
|
||||
import 'package:storytime/core/error/result.dart';
|
||||
|
||||
void main() {
|
||||
group('Result', () {
|
||||
const failure = UnexpectedFailure('boom');
|
||||
|
||||
group('Ok', () {
|
||||
test('map transforme la valeur', () {
|
||||
const result = Ok<int>(1);
|
||||
expect(result.map((v) => v + 1), equals(const Ok<int>(2)));
|
||||
});
|
||||
|
||||
test('map préserve le type Ok et produit la bonne valeur', () {
|
||||
const result = Ok<String>('hello');
|
||||
final mapped = result.map((v) => v.length);
|
||||
expect(mapped, isA<Ok<int>>());
|
||||
expect((mapped as Ok<int>).value, equals(5));
|
||||
});
|
||||
|
||||
test('fold appelle la branche ok', () {
|
||||
const result = Ok<int>(42);
|
||||
final out = result.fold(onOk: (v) => 'ok:$v', onErr: (_) => 'err');
|
||||
expect(out, equals('ok:42'));
|
||||
});
|
||||
|
||||
test('when appelle la branche ok et pas la branche err', () {
|
||||
const result = Ok<int>(7);
|
||||
var okCalled = false;
|
||||
var errCalled = false;
|
||||
result.when(ok: (_) => okCalled = true, err: (_) => errCalled = true);
|
||||
expect(okCalled, isTrue);
|
||||
expect(errCalled, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Err', () {
|
||||
test('map ne transforme pas — reste Err avec le même Failure', () {
|
||||
const result = Err<int>(failure);
|
||||
final mapped = result.map((v) => v + 1);
|
||||
expect(mapped, isA<Err<int>>());
|
||||
expect((mapped as Err<int>).failure, same(failure));
|
||||
});
|
||||
|
||||
test('fold appelle la branche err', () {
|
||||
const result = Err<int>(failure);
|
||||
final out = result.fold(
|
||||
onOk: (_) => 'ok',
|
||||
onErr: (f) => 'err:${f.message}',
|
||||
);
|
||||
expect(out, equals('err:boom'));
|
||||
});
|
||||
|
||||
test('when appelle la branche err et pas la branche ok', () {
|
||||
const result = Err<int>(failure);
|
||||
var okCalled = false;
|
||||
var errCalled = false;
|
||||
result.when(ok: (_) => okCalled = true, err: (_) => errCalled = true);
|
||||
expect(errCalled, isTrue);
|
||||
expect(okCalled, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Failure sous-types', () {
|
||||
test('NetworkFailure expose le message', () {
|
||||
const f = NetworkFailure('timeout');
|
||||
expect(f.message, equals('timeout'));
|
||||
});
|
||||
|
||||
test('NotFoundFailure expose le message', () {
|
||||
const f = NotFoundFailure('episode 42');
|
||||
expect(f.message, equals('episode 42'));
|
||||
});
|
||||
|
||||
test('UnexpectedFailure expose le message', () {
|
||||
const f = UnexpectedFailure('oops');
|
||||
expect(f.message, equals('oops'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:storytime/core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
group('AppTheme', () {
|
||||
testWidgets('useMaterial3 est activé', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(theme: AppTheme.light(), home: const SizedBox.shrink()),
|
||||
);
|
||||
|
||||
final theme = Theme.of(tester.element(find.byType(SizedBox)));
|
||||
expect(theme.useMaterial3, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('la couleur primaire correspond à la palette coucher', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(theme: AppTheme.light(), home: const SizedBox.shrink()),
|
||||
);
|
||||
|
||||
final theme = Theme.of(tester.element(find.byType(SizedBox)));
|
||||
// Palette « coucher » : violet/indigo doux.
|
||||
// On vérifie que la teinte de seed est bien dans le spectre attendu.
|
||||
final primary = theme.colorScheme.primary;
|
||||
// HSL : le bleu/violet doit dominer sur le rouge et le vert.
|
||||
final hsl = HSLColor.fromColor(primary);
|
||||
expect(
|
||||
hsl.hue,
|
||||
inInclusiveRange(200.0, 320.0),
|
||||
reason: 'La couleur primaire doit être dans le spectre bleu-violet',
|
||||
);
|
||||
});
|
||||
|
||||
test('AppTheme.light() retourne un ThemeData non nul', () {
|
||||
expect(AppTheme.light(), isA<ThemeData>());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user