From 02a121703f4cced938c1adf580b0f0e5cf59e024 Mon Sep 17 00:00:00 2001 From: Vincent Bourdon Date: Fri, 19 Jun 2026 17:30:45 +0200 Subject: [PATCH] feat(j0.3): socle transverse (Result/Failure, theme, router, DI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/error : Result 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) --- CLAUDE.md | 2 + ROADMAP.md | 4 +- lib/core/di/providers.dart | 6 +++ lib/core/error/failure.dart | 37 +++++++++++++++ lib/core/error/result.dart | 72 +++++++++++++++++++++++++++++ lib/core/router/app_router.dart | 50 ++++++++++++++++++++ lib/core/theme/app_theme.dart | 26 +++++++++++ lib/main.dart | 21 ++++----- test/core/result_test.dart | 82 +++++++++++++++++++++++++++++++++ test/core/theme_test.dart | 40 ++++++++++++++++ 10 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 lib/core/di/providers.dart create mode 100644 lib/core/error/failure.dart create mode 100644 lib/core/error/result.dart create mode 100644 lib/core/router/app_router.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 test/core/result_test.dart create mode 100644 test/core/theme_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 4fb34d5..5d7caac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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. diff --git a/ROADMAP.md b/ROADMAP.md index b36dd9d..aa027ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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/) diff --git a/lib/core/di/providers.dart b/lib/core/di/providers.dart new file mode 100644 index 0000000..a3bc019 --- /dev/null +++ b/lib/core/di/providers.dart @@ -0,0 +1,6 @@ +// Providers transverses (core). +// +// Convention : chaque feature expose ses propres providers dans +// `lib/features//presentation/providers.dart`. +// Ce fichier agrège les providers du core ; il restera vide tant qu'aucun +// provider transverse n'est nécessaire. diff --git a/lib/core/error/failure.dart b/lib/core/error/failure.dart new file mode 100644 index 0000000..5859c53 --- /dev/null +++ b/lib/core/error/failure.dart @@ -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); +} diff --git a/lib/core/error/result.dart b/lib/core/error/result.dart new file mode 100644 index 0000000..8f75f9d --- /dev/null +++ b/lib/core/error/result.dart @@ -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 { + const Result(); + + /// Transforme la valeur en cas de succès ; laisse [Err] intact. + Result map(T Function(S value) transform) => switch (this) { + Ok(:final value) => Ok(transform(value)), + Err(:final failure) => Err(failure), + }; + + /// Réduit le résultat à une valeur unique en fournissant les deux branches. + T fold({ + required T Function(S value) onOk, + required T Function(Failure failure) onErr, + }) => switch (this) { + Ok(:final value) => onOk(value), + Err(: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(:final value): + ok(value); + case Err(:final failure): + err(failure); + } + } +} + +/// Cas succès. +final class Ok extends Result { + const Ok(this.value); + + final S value; + + @override + bool operator ==(Object other) => other is Ok && other.value == value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => 'Ok($value)'; +} + +/// Cas échec. +final class Err extends Result { + const Err(this.failure); + + final Failure failure; + + @override + bool operator ==(Object other) => other is Err && other.failure == failure; + + @override + int get hashCode => failure.hashCode; + + @override + String toString() => 'Err($failure)'; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..9953855 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -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 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'))); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..0471529 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -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)), + ), + ); +} diff --git a/lib/main.dart b/lib/main.dart index ecfdd70..c9f8dce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( - title: 'Storytime', - home: Scaffold( - body: Center( - child: Text( - 'Storytime', - style: Theme.of(context).textTheme.headlineLarge, - ), - ), - ), - ); - } + Widget build(BuildContext context) => MaterialApp( + title: 'Storytime', + theme: AppTheme.light(), + initialRoute: AppRoutes.child, + routes: appRoutes, + ); } diff --git a/test/core/result_test.dart b/test/core/result_test.dart new file mode 100644 index 0000000..933fb34 --- /dev/null +++ b/test/core/result_test.dart @@ -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(1); + expect(result.map((v) => v + 1), equals(const Ok(2))); + }); + + test('map préserve le type Ok et produit la bonne valeur', () { + const result = Ok('hello'); + final mapped = result.map((v) => v.length); + expect(mapped, isA>()); + expect((mapped as Ok).value, equals(5)); + }); + + test('fold appelle la branche ok', () { + const result = Ok(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(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(failure); + final mapped = result.map((v) => v + 1); + expect(mapped, isA>()); + expect((mapped as Err).failure, same(failure)); + }); + + test('fold appelle la branche err', () { + const result = Err(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(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')); + }); + }); + }); +} diff --git a/test/core/theme_test.dart b/test/core/theme_test.dart new file mode 100644 index 0000000..dddc015 --- /dev/null +++ b/test/core/theme_test.dart @@ -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()); + }); + }); +}