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

351 lines
12 KiB
Markdown

<!-- markdownlint-disable-file -->
# 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
```yaml
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
```dart
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`.
```dart
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
```dart
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
```dart
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
```dart
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}`.
```dart
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
```dart
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`:
```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`.