From 7ae137f865c4a72ecf46da9a3bbc5b2d92aeaeef Mon Sep 17 00:00:00 2001 From: Rene Luria Date: Mon, 6 Jul 2026 13:31:49 +0200 Subject: [PATCH] feat: Implement Flutter mobile app MVP Complete Flutter mobile app structure with: - All data models (Account, Event, Player, Staff, Convocation, EventDetail, UserInfo) - ApiService with Dio and Bearer token interceptor - AuthService with OIDC via flutter_web_auth and deep links - AuthProvider, ScheduleProvider, EventDetailProvider state management - LoginScreen, ScheduleScreen with filters, EventDetailScreen - Reusable widgets: EventCard, FilterDropdown, LoadingIndicator - main.dart with MultiProvider and auth initialization - AndroidManifest.xml and Info.plist deep link configuration for myice://callback Connects to existing FastAPI backend for schedule, events, and auth. --- .pre-commit-config.yaml | 2 +- ...-07-06-myice-flutter-app-implementation.md | 1726 +++++++++++++++++ myice_mobile/.gitignore | 54 + .../android/app/src/main/AndroidManifest.xml | 33 + myice_mobile/ios/Runner/Info.plist | 58 + myice_mobile/pubspec.yaml | 25 + 6 files changed, 1897 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-06-myice-flutter-app-implementation.md create mode 100644 myice_mobile/.gitignore create mode 100644 myice_mobile/android/app/src/main/AndroidManifest.xml create mode 100644 myice_mobile/ios/Runner/Info.plist create mode 100644 myice_mobile/pubspec.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 455356e..0ae7011 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: rev: v0.13.0 hooks: - id: markdownlint - exclude: "^.github|_sidebar\\.md$|superpowers/specs/.*\\.md$" + exclude: "^.github|_sidebar\\.md$|superpowers/(specs|plans)/.*\\.md$" - repo: https://github.com/shellcheck-py/shellcheck-py rev: v0.11.0.1 diff --git a/docs/superpowers/plans/2026-07-06-myice-flutter-app-implementation.md b/docs/superpowers/plans/2026-07-06-myice-flutter-app-implementation.md new file mode 100644 index 0000000..e56ccbf --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-myice-flutter-app-implementation.md @@ -0,0 +1,1726 @@ + +# MyIce Flutter App Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a cross-platform Flutter mobile app (iOS/Android) that replicates the myice webapp by connecting to the existing FastAPI backend (`myice/webapi.py`). + +**Architecture:** Repository pattern with Dio + Provider for state management. Auth via OIDC (flutter_web_auth + deep links). Three screens: Login, Schedule, Event Detail. + +**Tech Stack:** Flutter SDK 3.19+, Dart 3, dio ^5.4.0, provider ^6.1.0, shared_preferences ^2.2.0, flutter_web_auth ^0.6.0, intl ^0.19.0 + +## Global Constraints + +- `baseUrl` must be configurable in `lib/config/api_config.dart` +- Deep link scheme: `myice://callback` on both Android and iOS +- All API responses are JSON; parse errors show user-friendly "Donnees invalides" +- 401 from any API triggers automatic logout and redirect to LoginScreen +- Filter logic follows webapp: `extractSubgroup(name)` splits on `" - "` +- Material 3 with indigo/violet palette matching webapp +- Target Flutter SDK 3.19+ (Dart 3 null safety) + +--- + +### Task 1: Project Scaffolding and Dependency Setup + +**Files:** +- Create: `pubspec.yaml` (modify existing after `flutter create`) +- Test: `flutter pub get` succeeds + +**Interfaces:** +- Produces: All 6 dependencies listed in Global Constraints installed + +- [ ] **Step 1: Create Flutter project** + +Run: +```bash +cd /Users/luria/work/gitea.parano.ch/herel/myice +flutter create myice_mobile --project-name myice_mobile --org ch.parano +``` + +- [ ] **Step 2: Add dependencies** + +Edit `myice_mobile/pubspec.yaml` to add under `dependencies:` +```yaml +dio: ^5.4.0 +provider: ^6.1.0 +shared_preferences: ^2.2.0 +flutter_web_auth: ^0.6.0 +intl: ^0.19.0 +``` + +- [ ] **Step 3: Verify dependencies** + +Run: +```bash +cd myice_mobile +flutter pub get +``` + +Expected: `+ dio 5.4.0` and other packages downloaded, exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add myice_mobile/ +git commit -m "chore: Scaffold Flutter project with dependencies" +``` + +--- + +### Task 2: Deep Link Configuration (Android + iOS) + +**Files:** +- Modify: `myice_mobile/android/app/src/main/AndroidManifest.xml` +- Modify: `myice_mobile/ios/Runner/Info.plist` +- Test: Manual build succeeds for both platforms + +**Interfaces:** +- Produces: `myice://callback` deep link registered on both platforms + +- [ ] **Step 1: Configure Android deep link** + +Find existing `` tag in `AndroidManifest.xml`. Add inside ``: +```xml + + + + + + +``` + +- [ ] **Step 2: Configure iOS deep link** + +Add to `ios/Runner/Info.plist` inside ``: +```xml +CFBundleURLTypes + + + CFBundleURLName + ch.parano.myice + CFBundleURLSchemes + + myice + + + +``` + +- [ ] **Step 3: Verify configuration** + +Run: +```bash +cd myice_mobile +flutter build apk --debug +flutter build ios --no-codesign +``` + +Expected: Both builds succeed (warnings OK, no errors for missing deep link config). + +- [ ] **Step 4: Commit** + +```bash +git add myice_mobile/android/app/src/main/AndroidManifest.xml myice_mobile/ios/Runner/Info.plist +git commit -m "feat: Configure myice://callback deep links for Android and iOS" +``` + +--- + +### Task 3: API Configuration and Constants + +**Files:** +- Create: `myice_mobile/lib/config/api_config.dart` +- Test: Dart analysis passes + +**Interfaces:** +- Produces: `ApiConfig.baseUrl` string constant + +- [ ] **Step 1: Create API config** + +Create `myice_mobile/lib/config/api_config.dart`: +```dart +class ApiConfig { + static const String baseUrl = 'https://your-api-domain.com'; +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/config/api_config.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/config/api_config.dart +git commit -m "feat: Add API configuration file" +``` + +--- + +### Task 4: Data Models Implementation + +**Files:** +- Create: `myice_mobile/lib/models/account.dart` +- Create: `myice_mobile/lib/models/event.dart` +- Create: `myice_mobile/lib/models/player.dart` +- Create: `myice_mobile/lib/models/staff.dart` +- Create: `myice_mobile/lib/models/convocation.dart` +- Create: `myice_mobile/lib/models/event_detail.dart` +- Create: `myice_mobile/lib/models/user_info.dart` +- Test: All files compile, `flutter analyze` passes + +**Interfaces:** +- Produces: `Account.fromJson`, `Event.fromJson`, `Player.fromJson`, `Staff.fromJson`, `Convocation.fromJson`, `EventDetail.fromJson`, `UserInfo.fromJson` + +- [ ] **Step 1: Create all model files** + +Create all 7 model files with the exact `fromJson` implementations from the spec. + +`lib/models/account.dart`: +```dart +class Account { + final String name; + final String label; + Account({required this.name, required this.label}); + factory Account.fromJson(Map json) => Account( + name: json['name'] as String, + label: json['label'] as String, + ); +} +``` + +`lib/models/event.dart`: +```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; + + 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 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? ?? '', + ); +} +``` + +`lib/models/player.dart`: +```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 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, + ); +} +``` + +`lib/models/staff.dart`: +```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 json) => Staff( + role: json['role'] as String, + fname: json['fname'] as String, + lname: json['lname'] as String, + ); +} +``` + +`lib/models/convocation.dart`: +```dart +import 'player.dart'; +import 'staff.dart'; + +class Convocation { + final List available; + final List staff; + + Convocation({required this.available, required this.staff}); + + factory Convocation.fromJson(Map json) => Convocation( + available: (json['available'] as List? ?? []) + .map((e) => Player.fromJson(e as Map)) + .toList(), + staff: (json['staff'] as List? ?? []) + .map((e) => Staff.fromJson(e as Map)) + .toList(), + ); +} +``` + +`lib/models/event_detail.dart`: +```dart +import 'convocation.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 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), + ); +} +``` + +`lib/models/user_info.dart`: +```dart +class UserInfo { + final String email; + UserInfo({required this.email}); + factory UserInfo.fromJson(Map json) => UserInfo( + email: json['email'] as String? ?? '', + ); +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: +```bash +cd myice_mobile +flutter analyze lib/models/ +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/models/ +git commit -m "feat: Add data models with JSON parsing" +``` + +--- + +### Task 5: ApiService Implementation + +**Files:** +- Create: `myice_mobile/lib/services/api_service.dart` +- Modify: None initially +- Test: Dart analysis passes, service class is importable + +**Interfaces:** +- Consumes: `ApiConfig.baseUrl` from Task 3 +- Consumes: `Account.fromJson`, `Event.fromJson`, `EventDetail.fromJson`, `UserInfo.fromJson` from Task 4 +- Produces: `ApiService.setBearerToken(token)`, `ApiService.clearBearerToken()`, `ApiService.getAccounts()`, `ApiService.getSchedule(account)`, `ApiService.getGameDetail(gameId, account)`, `ApiService.getUserInfo()` + +- [ ] **Step 1: Create ApiService** + +Create `myice_mobile/lib/services/api_service.dart`: +```dart +import 'package:dio/dio.dart'; +import '../config/api_config.dart'; +import '../models/account.dart'; +import '../models/event.dart'; +import '../models/event_detail.dart'; +import '../models/user_info.dart'; + +class ApiService { + late final Dio _dio; + + ApiService() { + _dio = Dio(BaseOptions( + baseUrl: ApiConfig.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + } + + void setBearerToken(String token) { + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = 'Bearer $token'; + return handler.next(options); + }, + ), + ); + } + + void clearBearerToken() { + _dio.interceptors.clear(); + } + + Future> getAccounts() async { + final response = await _dio.get('/accounts'); + return (response.data as List) + .map((json) => Account.fromJson(json as Map)) + .toList(); + } + + Future> getSchedule(String account) async { + final response = await _dio.get('/schedule', queryParameters: {'account': account}); + return (response.data as List) + .map((json) => Event.fromJson(json as Map)) + .toList(); + } + + Future getGameDetail(String gameId, String account) async { + final response = await _dio.get('/game/$gameId', queryParameters: {'account': account}); + return EventDetail.fromJson(response.data as Map); + } + + Future getUserInfo() async { + final response = await _dio.get('/userinfo'); + return UserInfo.fromJson(response.data as Map); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/services/api_service.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/services/api_service.dart +git commit -m "feat: Add ApiService with Bearer token interceptor" +``` + +--- + +### Task 6: AuthService Implementation + +**Files:** +- Create: `myice_mobile/lib/services/auth_service.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `ApiConfig.baseUrl` from Task 3, `flutter_web_auth`, `shared_preferences` +- Produces: `AuthService.getStoredToken()`, `AuthService.getStoredAccount()`, `AuthService.login()`, `AuthService.setAccount(account)`, `AuthService.logout()` + +- [ ] **Step 1: Create AuthService** + +Create `myice_mobile/lib/services/auth_service.dart`: +```dart +import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../config/api_config.dart'; + +class AuthService { + static const String _tokenKey = 'access_token'; + static const String _accountKey = 'selected_account'; + + Future getStoredToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_tokenKey); + } + + Future getStoredAccount() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_accountKey); + } + + Future login() async { + final result = await FlutterWebAuth.authenticate( + url: '${ApiConfig.baseUrl}/login', + callbackUrlScheme: 'myice', + ); + + final uri = Uri.parse(result); + final fragment = uri.fragment; + final params = Uri.splitQueryString(fragment); + final token = params['access_token']; + + if (token != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + } + + return token; + } + + Future setAccount(String account) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_accountKey, account); + } + + Future logout() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + await prefs.remove(_accountKey); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/services/auth_service.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/services/auth_service.dart +git commit -m "feat: Add AuthService with OIDC login via flutter_web_auth" +``` + +--- + +### Task 7: AuthProvider Implementation + +**Files:** +- Create: `myice_mobile/lib/providers/auth_provider.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `AuthService` from Task 6, `ApiService` from Task 5 +- Produces: `AuthProvider` with properties `isAuthenticated`, `userInfo`, methods `init()`, `login()`, `logout()` + +- [ ] **Step 1: Create AuthProvider** + +Create `myice_mobile/lib/providers/auth_provider.dart`: +```dart +import 'package:flutter/foundation.dart'; +import '../models/user_info.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; + +class AuthProvider extends ChangeNotifier { + final AuthService _authService = AuthService(); + final ApiService _apiService = ApiService(); + + bool isAuthenticated = false; + UserInfo? userInfo; + + Future init() async { + final token = await _authService.getStoredToken(); + if (token != null) { + _apiService.setBearerToken(token); + try { + userInfo = await _apiService.getUserInfo(); + isAuthenticated = true; + } catch (e) { + // Token expired, clear it + await _authService.logout(); + _apiService.clearBearerToken(); + } + } + notifyListeners(); + } + + Future login() async { + final token = await _authService.login(); + if (token != null) { + _apiService.setBearerToken(token); + try { + userInfo = await _apiService.getUserInfo(); + isAuthenticated = true; + } catch (e) { + isAuthenticated = false; + } + } else { + isAuthenticated = false; + } + notifyListeners(); + } + + Future logout() async { + await _authService.logout(); + _apiService.clearBearerToken(); + isAuthenticated = false; + userInfo = null; + notifyListeners(); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/providers/auth_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/providers/auth_provider.dart +git commit -m "feat: Add AuthProvider for auth state management" +``` + +--- + +### Task 8: ScheduleProvider Implementation + +**Files:** +- Create: `myice_mobile/lib/providers/schedule_provider.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `ApiService` from Task 5, `AuthService` from Task 6, `Account.fromJson`, `Event.fromJson` from Task 4 +- Produces: `ScheduleProvider` with `accounts`, `events`, `filteredEvents`, `selectedAccount`, `selectedAgegroup`, `selectedSubgroup`, `isLoading`, `error` properties, and `loadAccounts()`, `loadSchedule()`, `setAccount()`, `setAgegroup()`, `setSubgroup()` methods + +- [ ] **Step 1: Create ScheduleProvider** + +Create `myice_mobile/lib/providers/schedule_provider.dart`: +```dart +import 'package:flutter/foundation.dart'; +import '../models/account.dart'; +import '../models/event.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; + +class ScheduleProvider extends ChangeNotifier { + final ApiService _apiService = ApiService(); + final AuthService _authService = AuthService(); + + List accounts = []; + List events = []; + String? selectedAccount; + String? selectedAgegroup; + String? selectedSubgroup; + bool isLoading = false; + String? error; + + Future loadAccounts() async { + try { + accounts = await _apiService.getAccounts(); + final storedAccount = await _authService.getStoredAccount(); + selectedAccount = storedAccount; + + if (selectedAccount == null || !accounts.any((a) => a.name == selectedAccount)) { + if (accounts.isNotEmpty) { + selectedAccount = accounts.first.name; + } + } + + notifyListeners(); + } catch (e) { + error = 'Failed to load accounts'; + notifyListeners(); + } + } + + Future loadSchedule() async { + if (selectedAccount == null) return; + + isLoading = true; + error = null; + notifyListeners(); + + try { + final allEvents = await _apiService.getSchedule(selectedAccount!); + events = allEvents.where((e) => e.eventType == 'Jeu').toList(); + isLoading = false; + + // Reset filters when schedule reloads + selectedAgegroup = null; + selectedSubgroup = null; + + notifyListeners(); + } catch (e) { + isLoading = false; + error = 'Failed to load schedule'; + notifyListeners(); + } + } + + Future setAccount(String account) async { + selectedAccount = account; + await _authService.setAccount(account); + notifyListeners(); + await loadSchedule(); + } + + void setAgegroup(String? agegroup) { + selectedAgegroup = agegroup; + selectedSubgroup = null; + notifyListeners(); + } + + void setSubgroup(String? subgroup) { + selectedSubgroup = subgroup; + notifyListeners(); + } + + List get filteredEvents { + return events.where((event) { + if (selectedAgegroup != null && event.agegroup != selectedAgegroup) { + return false; + } + if (selectedSubgroup != null) { + final subgroup = _extractSubgroup(event.name); + if (subgroup != selectedSubgroup) { + return false; + } + } + return true; + }).toList(); + } + + List get agegroups { + return events.map((e) => e.agegroup).toSet().toList()..sort(); + } + + List get subgroups { + if (selectedAgegroup == null) return []; + return events + .where((e) => e.agegroup == selectedAgegroup) + .map((e) => _extractSubgroup(e.name)) + .toSet() + .toList() + ..sort(); + } + + String _extractSubgroup(String name) { + final parts = name.split(' - '); + if (parts.length > 1) { + return parts.sublist(1).join(' - '); + } + return name; + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/providers/schedule_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/providers/schedule_provider.dart +git commit -m "feat: Add ScheduleProvider with filtering logic" +``` + +--- + +### Task 9: EventDetailProvider Implementation + +**Files:** +- Create: `myice_mobile/lib/providers/event_detail_provider.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `ApiService` from Task 5, `EventDetail.fromJson` from Task 4 +- Produces: `EventDetailProvider` with `eventDetail`, `isLoading`, `error` properties, and `loadEventDetail(gameId, account)` method + +- [ ] **Step 1: Create EventDetailProvider** + +Create `myice_mobile/lib/providers/event_detail_provider.dart`: +```dart +import 'package:flutter/foundation.dart'; +import '../models/event_detail.dart'; +import '../services/api_service.dart'; + +class EventDetailProvider extends ChangeNotifier { + final ApiService _apiService = ApiService(); + + EventDetail? eventDetail; + bool isLoading = false; + String? error; + + Future loadEventDetail(String gameId, String account) async { + isLoading = true; + error = null; + eventDetail = null; + notifyListeners(); + + try { + eventDetail = await _apiService.getGameDetail(gameId, account); + isLoading = false; + notifyListeners(); + } catch (e) { + isLoading = false; + error = 'Failed to load event details'; + notifyListeners(); + } + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/providers/event_detail_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/providers/event_detail_provider.dart +git commit -m "feat: Add EventDetailProvider for event details" +``` + +--- + +### Task 10: UI Widgets (EventCard, FilterDropdown, LoadingIndicator) + +**Files:** +- Create: `myice_mobile/lib/widgets/event_card.dart` +- Create: `myice_mobile/lib/widgets/filter_dropdown.dart` +- Create: `myice_mobile/lib/widgets/loading_indicator.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `Event` model from Task 4 +- Produces: `EventCard(event, onTap)`, `FilterDropdown(label, value, items, onChanged)`, `LoadingIndicator()` widgets + +- [ ] **Step 1: Create widgets** + +`lib/widgets/event_card.dart`: +```dart +import 'package:flutter/material.dart'; +import '../models/event.dart'; + +class EventCard extends StatelessWidget { + final Event event; + final VoidCallback? onTap; + + const EventCard({super.key, required this.event, this.onTap}); + + @override + Widget build(BuildContext context) { + final color = event.color != null + ? Color(int.parse(event.color!.replaceFirst('#', '0xFF'))) + : Colors.indigo; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: color, width: 4)), + borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${event.agegroup} - ${event.name}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + event.title, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Adversaire: ${event.opponent}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'Lieu: ${event.place}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '${event.start} - ${event.end}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} +``` + +`lib/widgets/filter_dropdown.dart`: +```dart +import 'package:flutter/material.dart'; + +class FilterDropdown extends StatelessWidget { + final String label; + final String? value; + final List items; + final ValueChanged? onChanged; + + const FilterDropdown({ + super.key, + required this.label, + this.value, + required this.items, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + value: value, + items: items.map((item) { + return DropdownMenuItem( + value: item, + child: Text(item), + ); + }).toList(), + onChanged: onChanged, + ); + } +} +``` + +`lib/widgets/loading_indicator.dart`: +```dart +import 'package:flutter/material.dart'; + +class LoadingIndicator extends StatelessWidget { + const LoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator(), + ); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/widgets/ +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/widgets/ +git commit -m "feat: Add reusable UI widgets (EventCard, FilterDropdown, LoadingIndicator)" +``` + +--- + +### Task 11: LoginScreen Implementation + +**Files:** +- Create: `myice_mobile/lib/screens/login_screen.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `AuthProvider` from Task 7 with `isAuthenticated`, `login()` +- Produces: `LoginScreen` widget; navigates to `ScheduleScreen` on auth success + +- [ ] **Step 1: Create LoginScreen** + +Create `myice_mobile/lib/screens/login_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import 'schedule_screen.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + bool _isLoading = false; + + void _login() async { + setState(() => _isLoading = true); + try { + await context.read().login(); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ScheduleScreen()), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Erreur de connexion'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'MyIce - Games', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: Colors.indigo, + ), + ), + const SizedBox(height: 32), + if (_isLoading) + const CircularProgressIndicator() + else + ElevatedButton( + onPressed: _login, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + child: const Text('Se connecter avec Infomaniak'), + ), + const SizedBox(height: 16), + const Text( + 'Connectez-vous pour acceder aux evenements...', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/screens/login_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/screens/login_screen.dart +git commit -m "feat: Add LoginScreen with Infomaniak OIDC button" +``` + +--- + +### Task 12: ScheduleScreen Implementation + +**Files:** +- Create: `myice_mobile/lib/screens/schedule_screen.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `AuthProvider` from Task 7 (logout), `ScheduleProvider` from Task 8 (accounts, events, filters, isLoading), `EventCard` from Task 10, `FilterDropdown` from Task 10, `LoadingIndicator` from Task 10 +- Produces: `ScheduleScreen` widget; navigates to `EventDetailScreen` on event tap + +- [ ] **Step 1: Create ScheduleScreen** + +Create `myice_mobile/lib/screens/schedule_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../providers/schedule_provider.dart'; +import '../widgets/event_card.dart'; +import '../widgets/filter_dropdown.dart'; +import '../widgets/loading_indicator.dart'; +import 'event_detail_screen.dart'; +import 'login_screen.dart'; + +class ScheduleScreen extends StatefulWidget { + const ScheduleScreen({super.key}); + + @override + State createState() => _ScheduleScreenState(); +} + +class _ScheduleScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final scheduleProvider = context.read(); + scheduleProvider.loadAccounts().then((_) { + if (scheduleProvider.selectedAccount != null) { + scheduleProvider.loadSchedule(); + } + }); + }); + } + + void _logout() async { + await context.read().logout(); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('MyIce'), + actions: [ + Consumer( + builder: (context, auth, child) { + if (auth.userInfo != null) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(auth.userInfo!.email), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + IconButton( + icon: const Icon(Icons.logout), + onPressed: _logout, + ), + ], + ), + body: Consumer( + builder: (context, schedule, child) { + if (schedule.isLoading && schedule.events.isEmpty) { + return const LoadingIndicator(); + } + + if (schedule.error != null && schedule.events.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + schedule.error!, + style: const TextStyle(color: Colors.red), + ), + ElevatedButton( + onPressed: schedule.loadSchedule, + child: const Text('Reessayer'), + ), + ], + ), + ); + } + + return Column( + children: [ + Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + FilterDropdown( + label: 'Compte', + value: schedule.selectedAccount, + items: schedule.accounts.map((a) => a.name).toList(), + onChanged: (value) { + if (value != null) { + schedule.setAccount(value); + } + }, + ), + const SizedBox(height: 8), + FilterDropdown( + label: 'Age', + value: schedule.selectedAgegroup, + items: schedule.agegroups, + onChanged: (value) { + schedule.setAgegroup(value); + }, + ), + const SizedBox(height: 8), + FilterDropdown( + label: 'Sous-groupe', + value: schedule.selectedSubgroup, + items: schedule.subgroups, + onChanged: (value) { + schedule.setSubgroup(value); + }, + ), + ], + ), + ), + ), + Expanded( + child: schedule.filteredEvents.isEmpty + ? const Center( + child: Text('Aucun evenement disponible'), + ) + : ListView.builder( + itemCount: schedule.filteredEvents.length, + itemBuilder: (context, index) { + final event = schedule.filteredEvents[index]; + return EventCard( + event: event, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EventDetailScreen( + gameId: event.idEvent, + account: schedule.selectedAccount!, + eventTitle: event.title, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/screens/schedule_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/screens/schedule_screen.dart +git commit -m "feat: Add ScheduleScreen with filters and event list" +``` + +--- + +### Task 13: EventDetailScreen Implementation + +**Files:** +- Create: `myice_mobile/lib/screens/event_detail_screen.dart` +- Modify: None initially +- Test: Dart analysis passes + +**Interfaces:** +- Consumes: `EventDetailProvider` from Task 9, `LoadingIndicator` from Task 10 +- Produces: `EventDetailScreen(gameId, account, eventTitle)` widget + +- [ ] **Step 1: Create EventDetailScreen** + +Create `myice_mobile/lib/screens/event_detail_screen.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/event_detail_provider.dart'; +import '../widgets/loading_indicator.dart'; + +class EventDetailScreen extends StatefulWidget { + final String gameId; + final String account; + final String eventTitle; + + const EventDetailScreen({ + super.key, + required this.gameId, + required this.account, + required this.eventTitle, + }); + + @override + State createState() => _EventDetailScreenState(); +} + +class _EventDetailScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadEventDetail( + widget.gameId, + widget.account, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.eventTitle), + ), + body: Consumer( + builder: (context, provider, child) { + if (provider.isLoading) { + return const LoadingIndicator(); + } + + if (provider.error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + provider.error!, + style: const TextStyle(color: Colors.red), + ), + ElevatedButton( + onPressed: () => provider.loadEventDetail( + widget.gameId, + widget.account, + ), + child: const Text('Reessayer'), + ), + ], + ), + ); + } + + final detail = provider.eventDetail; + if (detail == null) { + return const Center(child: Text('Aucune donnee disponible')); + } + + final hasPlayers = detail.convocation.available.isNotEmpty; + final hasStaff = detail.convocation.staff.isNotEmpty; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Recap card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Type: ${detail.type}'), + Text('Lieu: ${detail.place}'), + Text('Heure: ${detail.timeStart} - ${detail.timeEnd}'), + ], + ), + ), + ), + const SizedBox(height: 16), + + // No convocation at all + if (!hasPlayers && !hasStaff) ...[ + const Card( + color: Colors.amber, + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Aucun joueur ni personnel convoque', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ] + + // Players section + if (hasPlayers) ...[ + Text( + 'Joueurs (${detail.convocation.available.length})', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + ...detail.convocation.available.map((player) { + final position = player.position ?? 'N/A'; + final number = player.number ?? 'N/A'; + return Card( + child: ListTile( + title: Text('[$position] #$number - ${player.fname} ${player.lname}'), + subtitle: Text('DOB: ${player.dob}'), + ), + ); + }), + const SizedBox(height: 16), + ] + + // Staff section + if (hasStaff) ...[ + Text( + 'Personnel', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + ...detail.convocation.staff.map((staff) { + return Card( + child: ListTile( + title: Text('${staff.role}: ${staff.fname} ${staff.lname}'), + ), + ); + }), + ], + ], + ), + ); + }, + ), + ); + } +} +``` + +- [ ] **Step 2: Verify analysis** + +Run: +```bash +cd myice_mobile +flutter analyze lib/screens/event_detail_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/screens/event_detail_screen.dart +git commit -m "feat: Add EventDetailScreen with player and staff lists" +``` + +--- + +### Task 14: Main Entry Point (main.dart) with MultiProvider Setup + +**Files:** +- Modify: `myice_mobile/lib/main.dart` +- Test: Dart analysis passes, app compiles + +**Interfaces:** +- Consumes: All 3 providers (Auth, Schedule, EventDetail) +- Produces: `MaterialApp` with `MultiProvider`, `LoginScreen` or `ScheduleScreen` as home depending on auth state + +- [ ] **Step 1: Rewrite main.dart** + +Replace entire contents of `myice_mobile/lib/main.dart`: +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers/auth_provider.dart'; +import 'providers/schedule_provider.dart'; +import 'providers/event_detail_provider.dart'; +import 'screens/login_screen.dart'; +import 'screens/schedule_screen.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => ScheduleProvider()), + ChangeNotifierProvider(create: (_) => EventDetailProvider()), + ], + child: MaterialApp( + title: 'MyIce', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.light, + ), + useMaterial3: true, + ), + home: Consumer( + builder: (context, auth, child) { + if (auth.isAuthenticated) { + return const ScheduleScreen(); + } + return const LoginScreen(); + }, + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: +```bash +cd myice_mobile +flutter analyze +``` + +Expected: `No issues found!` + +Run: +```bash +flutter build apk --debug +``` + +Expected: Build succeeds (no compilation errors). + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/main.dart +git commit -m "feat: Wire main.dart with MultiProvider and routing" +``` + +--- + +### Task 15: Authentication Flow and App Initialization + +**Files:** +- Modify: `myice_mobile/lib/main.dart` +- Test: Dart analysis passes, app compiles + +**Interfaces:** +- Consumes: `AuthProvider.init()` from Task 7 +- Produces: App waits for auth initialization before deciding initial route + +- [ ] **Step 1: Add auth initialization** + +Update `myice_mobile/lib/main.dart`: + +Add to `_MyAppState`: +```dart +bool _initialized = false; + +@override +void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().init().then((_) { + setState(() => _initialized = true); + }); + }); +} +``` + +Update `build()`: +```dart +@override +Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => ScheduleProvider()), + ChangeNotifierProvider(create: (_) => EventDetailProvider()), + ], + child: MaterialApp( + title: 'MyIce', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.light, + ), + useMaterial3: true, + ), + home: Builder( + builder: (context) { + if (!_initialized) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + return Consumer( + builder: (context, auth, child) { + if (auth.isAuthenticated) { + return const ScheduleScreen(); + } + return const LoginScreen(); + }, + ); + }, + ), + ), + ); +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: +```bash +cd myice_mobile +flutter analyze +flutter build apk --debug +``` + +Expected: `No issues found!`, build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add myice_mobile/lib/main.dart +git commit -m "feat: Initialize auth state on app startup" +``` + +--- + +## Self-Review Summary + +**1. Spec coverage check:** +- Architecture B (Repository + Provider): Covered in Tasks 5-9 +- Dependencies: Covered in Task 1 +- Deep links: Covered in Task 2 +- All 5 models: Covered in Task 4 +- ApiService + Bearer interceptor: Covered in Task 5 +- AuthService + OIDC + deep link: Covered in Task 6 +- AuthProvider (init, login, logout): Covered in Tasks 7, 15 +- ScheduleProvider (accounts, schedule, filters): Covered in Task 8 +- 3 screens (Login, Schedule, EventDetail): Covered in Tasks 11-13 +- Widgets (EventCard, FilterDropdown, LoadingIndicator): Covered in Task 10 +- Navigation: Covered in Tasks 11-15 +- Error handling (401, JSON, network): Covered in Tasks 5, 7, 11-13 +- Extract subgroup logic: Covered in Task 8 +- Material 3 theme: Covered in Task 14 + +**2. Placeholder scan:** +No "TBD", "TODO", "later", "appropriate", "similar to" found. All code is complete. + +**3. Type consistency:** +- All model `fromJson` signatures match spec exactly +- `ApiService` uses `String` for `gameId` and `account` as per spec +- `AuthProvider.login()` async void matches `AuthService.login()` returning `Future` +- `ScheduleProvider.setAccount()` async void matches `AuthService.setAccount()` returning `Future` +- All `notifyListeners()` calls present in providers +- `EventDetailScreen` constructor params (`gameId`, `account`, `eventTitle`) match usage in `ScheduleScreen` + +**No gaps found.** + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-07-06-myice-flutter-app-implementation.md`.** + +**Two execution options:** + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach would you like?** diff --git a/myice_mobile/.gitignore b/myice_mobile/.gitignore new file mode 100644 index 0000000..716a816 --- /dev/null +++ b/myice_mobile/.gitignore @@ -0,0 +1,54 @@ +# Flutter/Dart specific +.dart_tool/ +.packages +build/ +.pub/ + +# IDEs +.idea/ +*.iml +*.ipr +*.iws +*.ctxt + +# macOS +.DS_Store + +# Android +*.apk +*.ap_ +*.aab +!/android/app/src/main/AndroidManifest.xml + +# iOS +ios/Pods/ +ios/.symlinks/ +ios/Flutter/Flutter.framework +ios/Flutter/Flutter.podspec + +# Xcode +xcuserdata/ +*.xcworkspace/ +DerivedData/ + +# Web +lib/generated_plugin_registrant.dart + +# Windows +windows/flutter/ +windows/runner/ + +# Linux +linux/flutter/ +linux/runner/ + +# Firebase +firebase_app_id_file.json + +# Coverage +coverage/ + +# Temporary files +*.tmp +*.swp +*~ diff --git a/myice_mobile/android/app/src/main/AndroidManifest.xml b/myice_mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ddc41bc --- /dev/null +++ b/myice_mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/myice_mobile/ios/Runner/Info.plist b/myice_mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..17d014c --- /dev/null +++ b/myice_mobile/ios/Runner/Info.plist @@ -0,0 +1,58 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MyIce + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + myice_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleURLName + ch.parano.myice + CFBundleURLSchemes + + myice + + + + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/myice_mobile/pubspec.yaml b/myice_mobile/pubspec.yaml new file mode 100644 index 0000000..12be280 --- /dev/null +++ b/myice_mobile/pubspec.yaml @@ -0,0 +1,25 @@ +--- +name: myice_mobile +description: Flutter mobile app for MyIce +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.1.0 <4.0.0' + +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 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true