Files
myice/docs/superpowers/specs/2026-07-06-myice-flutter-app-design.md
T
herel cabd121ac1 docs: Add Flutter app design specification
Add the full design specification for the MyIce Flutter mobile app.
The app connects to the existing FastAPI backend and replicates
the webapp functionality with OIDC auth, schedule browsing,
and event detail views.
2026-07-06 13:25:20 +02:00

12 KiB

MyIce Flutter App - Design Specification

Date: 2026-07-06 Context: Application mobile Flutter (iOS/Android) reproduisant le fonctionnement de la webapp myice existante. Backend: L'application se connecte a l'API FastAPI existante (myice/webapi.py) via HTTP.

1. Architecture globale

Approche : Repository + Provider (B)

lib/
├── main.dart                  # Entry point, MaterialApp, MultiProvider
├── config/
│   └── api_config.dart         # URL base de l'API, timeouts
├── models/
│   ├── account.dart            # {name, label}
│   ├── event.dart              # Game/Practice du schedule
│   ├── event_detail.dart       # Details + convocation
│   ├── user_info.dart          # {email}
│   └── convocation.dart        # Player, Staff, Convocation
├── services/
│   ├── api_service.dart        # Dio + intercepteur Bearer
│   └── auth_service.dart       # Login/logout OIDC, deep link
├── providers/
│   ├── auth_provider.dart      # Etat auth + user info
│   ├── schedule_provider.dart  # Liste events + filtres
│   └── event_provider.dart     # Details d'un event (optionnel)
├── repositories/
│   └── myice_repository.dart   # Appels API : accounts, schedule, game
├── screens/
│   ├── login_screen.dart
│   ├── schedule_screen.dart
│   └── event_detail_screen.dart
└── widgets/
    ├── event_card.dart
    ├── filter_dropdown.dart
    └── loading_indicator.dart

Pattern : Repository encapsule les appels API -> Providers gerent l'etat reactif -> Screens ecoutent les Providers.

2. Dependances Flutter

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0
  provider: ^6.1.0
  shared_preferences: ^2.2.0
  flutter_web_auth: ^0.6.0
  intl: ^0.19.0  # Pour le parsing/formatage de dates si besoin

3. Modeles de donnees (JSON <-> Dart)

Account

class Account {
  final String name;
  final String label;
  Account({required this.name, required this.label});
  factory Account.fromJson(Map<String, dynamic> json) => Account(
    name: json['name'] as String,
    label: json['label'] as String,
  );
}

Event

Provenant de /schedule.

class Event {
  final String idEvent;
  final String agegroup;
  final String name;
  final String title;
  final String opponent;
  final String place;
  final String start;
  final String end;
  final String? color;
  final String eventType; // mappe depuis le champ JSON 'event'

  Event({
    required this.idEvent,
    required this.agegroup,
    required this.name,
    required this.title,
    required this.opponent,
    required this.place,
    required this.start,
    required this.end,
    this.color,
    required this.eventType,
  });

  factory Event.fromJson(Map<String, dynamic> json) => Event(
    idEvent: json['id_event']?.toString() ?? '',
    agegroup: json['agegroup'] as String,
    name: json['name'] as String? ?? '',
    title: json['title'] as String,
    opponent: json['opponent'] as String? ?? '',
    place: json['place'] as String? ?? '',
    start: json['start'] as String,
    end: json['end'] as String,
    color: json['color'] as String?,
    eventType: json['event'] as String? ?? '',
  );
}

Player

class Player {
  final String? position;
  final String? number;
  final String fname;
  final String lname;
  final String dob;

  Player({this.position, this.number, required this.fname, required this.lname, required this.dob});

  factory Player.fromJson(Map<String, dynamic> json) => Player(
    position: json['position'] as String?,
    number: json['number']?.toString(),
    fname: json['fname'] as String,
    lname: json['lname'] as String,
    dob: json['dob'] as String,
  );
}

Staff

class Staff {
  final String role;
  final String fname;
  final String lname;

  Staff({required this.role, required this.fname, required this.lname});

  factory Staff.fromJson(Map<String, dynamic> json) => Staff(
    role: json['role'] as String,
    fname: json['fname'] as String,
    lname: json['lname'] as String,
  );
}

Convocation

class Convocation {
  final List<Player> available;
  final List<Staff> staff;

  Convocation({required this.available, required this.staff});

  factory Convocation.fromJson(Map<String, dynamic> json) => Convocation(
    available: (json['available'] as List? ?? [])
        .map((e) => Player.fromJson(e as Map<String, dynamic>))
        .toList(),
    staff: (json['staff'] as List? ?? [])
        .map((e) => Staff.fromJson(e as Map<String, dynamic>))
        .toList(),
  );
}

EventDetail

Provenant de /game/{id}.

class EventDetail {
  final String title;
  final String type;
  final String place;
  final String timeStart;
  final String timeEnd;
  final Convocation convocation;

  EventDetail({
    required this.title,
    required this.type,
    required this.place,
    required this.timeStart,
    required this.timeEnd,
    required this.convocation,
  });

  factory EventDetail.fromJson(Map<String, dynamic> json) => EventDetail(
    title: json['title'] as String,
    type: json['type'] as String,
    place: json['place'] as String? ?? '',
    timeStart: json['time_start'] as String,
    timeEnd: json['time_end'] as String,
    convocation: Convocation.fromJson(json['convocation'] as Map<String, dynamic>),
  );
}

UserInfo

class UserInfo {
  final String email;
  UserInfo({required this.email});
  factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
    email: json['email'] as String? ?? '',
  );
}

4. Services

ApiService

Utilise dio. Un intercepteur ajoute automatiquement le header Authorization: Bearer <token>.

Concept:

  • setBearerToken(token) ajoute un InterceptorsWrapper a Dio.
  • clearBearerToken() retire les intercepteurs existants.
  • Methodes: getAccounts(), getSchedule(account), getGameDetail(gameId, account), getUserInfo().
  • Retournent des objets modeles via fromJson.

AuthService

Utilise flutter_web_auth pour l'OIDC + shared_preferences pour stocker le token.

Concept:

  • storedToken, storedAccount depuis SharedPreferences.
  • login(): appelle FlutterWebAuth.authenticate(url: baseUrl/login, callbackUrlScheme: 'myice'). Parse le fragment #access_token=xxx de l'URL retournee. Stocke le token.
  • setAccount(account): persiste le compte selectionne.
  • logout(): supprime token, account, userInfo des prefs.

Configuration deep link requise:

  • Android: intent-filter dans AndroidManifest.xml pour scheme myice://.
  • iOS: CFBundleURLTypes dans Info.plist avec scheme myice.

5. Providers (Provider package)

AuthProvider

Gere l'etat global d'authentification.

Etat: isAuthenticated, userInfo. Methodes:

  • init(): Verifie token stocke, appelle /userinfo. Si 401, considere deconnecte.
  • login(): Lance AuthService.login(), met a jour le token, appelle /userinfo, notifie listeners.
  • logout(): Efface le token, notifie listeners.

ScheduleProvider

Gere les events, les comptes, et les filtres.

Etat: accounts, events, selectedAccount, selectedAgegroup, selectedSubgroup, isLoading, error. Methodes:

  • loadAccounts(): Appelle /accounts, met a jour accounts. Selectionne storedAccount si present, sinon le premier.
  • loadSchedule(): Met isLoading = true, appelle /schedule?account=selectedAccount, filtre events ou eventType == "Jeu", extrait les agegroup uniques pour les filtres.
  • setAccount(String): Met a jour selectedAccount, sauvegarde dans SharedPreferences, appelle loadSchedule().
  • setAgegroup(String): Met a jour, reset selectedSubgroup, notifie.
  • setSubgroup(String): Met a jour, notifie.
  • get filteredEvents: Getter filtre events par agegroup et subgroup.

Logique de filtre sous-groupe: Identique a la webapp. La fonction extractSubgroup(name) retire la partie apres " - " dans le name de l'event pour obtenir le sous-groupe (si applicable).

EventDetailProvider (Optionnel)

Gere le chargement des details d'un event. Peut etre integre dans ScheduleProvider si prefere pour garder un seul provider.

Etat: eventDetail, isLoading, error. Methodes: loadEventDetail(gameId) appelle /game/{gameId}.

6. Ecrans

LoginScreen

  • AppBar: Aucune (centre).
  • Body: Column centree.
    • Titre: "MyIce - Games" (headlineLarge, couleur indigo).
    • Bouton: "Se connecter avec Infomaniak" (style degrade indigo -> violet).
    • Texte d'aide: "Connectez-vous pour acceder aux evenements...".
  • Comportement:
    • Si authProvider.isAuthenticated, navigue automatiquement vers ScheduleScreen.
    • Affiche CircularProgressIndicator pendant le login.
    • SnackBar en cas d'erreur.

ScheduleScreen

  • AppBar: Titre "MyIce", action logout + email utilisateur.
  • Body:
    • Filtres: Card avec 3 DropdownButtonFormField (Compte, Age, Sous-groupe). Le bouton "Charger" est optionnel (changement de compte recharge auto).
    • Liste: Expanded + ListView.builder. Affiche EventCard pour chaque event filtre.
    • Etats: Loading (CircularProgressIndicator), vide (texte info), erreur (SnackBar).

EventDetailScreen

  • AppBar: Titre = event.title, bouton retour.
  • Body: SingleChildScrollView + Padding(16).
    • Card recap: Type, Lieu, Heure.
    • Section Joueurs:
      • Si vide: Card amber "Aucun joueur convoque".
      • Sinon: Card avec total + repartition positions. Liste triee par position puis numero: [Position] #Num - Prenom Nom (DOB).
    • Section Personnel:
      • Si vide: Card bleu "Aucun personnel convoque".
      • Sinon: Liste role: Prenom Nom.
    • Si aucun joueur ET aucun personnel: Card amber principal "Aucun joueur ni personnel convoque" uniquement.

Widgets reutilisables

  • EventCard: Card avec bordure laterale coloree. Titre (agegroup - name), sous-titre, details (adversaire, lieu, heure). onTap -> EventDetailScreen.
  • FilterDropdown: DropdownButtonFormField generique stylise.

7. Navigation & Routing

Utilisation de Navigator classique (pas de package tierce pour 3 ecrans).

  • main.dart: MaterialApp avec home determine par Consumer<AuthProvider> (ScheduleScreen ou LoginScreen).
  • Navigation details: Navigator.push(MaterialPageRoute(builder: (_) => EventDetailScreen(...))).

8. Gestion des erreurs

  • Erreur reseau / API: SnackBar rouge avec message.
  • 401 (Token expire): Deconnexion automatique + redirection LoginScreen.
  • JSON malforme: La webapp gere cote backend. Cote Flutter, jsonDecode echoue -> message "Donnees invalides".
  • Aucun event/joueur: Interface vide avec message informatif (pas d'erreur).

9. Configuration requise

Android

Ajouter un intent-filter dans android/app/src/main/AndroidManifest.xml pour le deep link myice://callback.

iOS

Ajouter CFBundleURLTypes dans ios/Runner/Info.plist avec scheme myice.

Configuration API

lib/config/api_config.dart:

class ApiConfig {
  static const String baseUrl = 'https://your-api-domain.com'; // A configurer
}

10. Plan d'implementation (High-level)

  1. Scaffolding du projet Flutter (flutter create).
  2. Ajout des dependances (pubspec.yaml).
  3. Configuration des deep links (AndroidManifest + Info.plist).
  4. Implementation des modeles de donnees (fromJson).
  5. Implementation des services (ApiService, AuthService).
  6. Implementation des providers (AuthProvider, ScheduleProvider).
  7. Implementation des ecrans (LoginScreen, ScheduleScreen, EventDetailScreen).
  8. Implementation des widgets (EventCard, etc.).
  9. Wiring dans main.dart avec MultiProvider.
  10. Tests manuels de l'auth OIDC et de la navigation.

Prochaine etape apres validation du spec: Creer le plan d'implementation detaille avec le skill writing-plans.