initial import

This commit is contained in:
2026-07-06 18:01:38 +02:00
commit 8ce435e298
122 changed files with 5392 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
class ApiConfig {
static const String baseUrl = 'https://myice.parano.ch';
}
+81
View File
@@ -0,0 +1,81 @@
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';
import 'services/api_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final apiService = ApiService();
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider(apiService)),
ChangeNotifierProvider(create: (_) => ScheduleProvider(apiService)),
ChangeNotifierProvider(create: (_) => EventDetailProvider(apiService)),
],
child: MaterialApp(
title: 'MyIce',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
useMaterial3: true,
),
home: const AppRoot(),
),
);
}
}
class AppRoot extends StatefulWidget {
const AppRoot({super.key});
@override
State<AppRoot> createState() => _AppRootState();
}
class _AppRootState extends State<AppRoot> {
bool _initialized = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AuthProvider>().init().then((_) {
if (mounted) {
setState(() => _initialized = true);
}
});
});
}
@override
Widget build(BuildContext context) {
if (!_initialized) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
return Consumer<AuthProvider>(
builder: (context, auth, child) {
if (auth.isAuthenticated) {
return const ScheduleScreen();
}
return const LoginScreen();
},
);
}
}
+9
View File
@@ -0,0 +1,9 @@
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,
);
}
+18
View File
@@ -0,0 +1,18 @@
import 'player.dart';
import 'staff.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(),
);
}
+51
View File
@@ -0,0 +1,51 @@
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<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? ?? '',
);
Map<String, dynamic> toJson() => {
'id_event': idEvent,
'agegroup': agegroup,
'name': name,
'title': title,
'opponent': opponent,
'place': place,
'start': start,
'end': end,
'color': color,
'event': eventType,
};
}
+28
View File
@@ -0,0 +1,28 @@
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<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>),
);
}
+17
View File
@@ -0,0 +1,17 @@
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,
);
}
+13
View File
@@ -0,0 +1,13 @@
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,
);
}
+7
View File
@@ -0,0 +1,7 @@
class UserInfo {
final String email;
UserInfo({required this.email});
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
email: json['email'] as String? ?? '',
);
}
+54
View File
@@ -0,0 +1,54 @@
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;
bool isAuthenticated = false;
UserInfo? userInfo;
AuthProvider(this._apiService);
Future<void> 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<void> 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<void> logout() async {
await _authService.logout();
_apiService.clearBearerToken();
isAuthenticated = false;
userInfo = null;
notifyListeners();
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/foundation.dart';
import '../models/event_detail.dart';
import '../services/api_service.dart';
class EventDetailProvider extends ChangeNotifier {
final ApiService _apiService;
EventDetail? eventDetail;
bool isLoading = false;
String? error;
EventDetailProvider(this._apiService);
Future<void> 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();
}
}
}
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/foundation.dart';
import '../models/account.dart';
import '../models/event.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../services/schedule_cache_service.dart';
class ScheduleProvider extends ChangeNotifier {
final ApiService _apiService;
final AuthService _authService = AuthService();
final ScheduleCacheService _cacheService = ScheduleCacheService();
List<Account> accounts = [];
List<Event> events = [];
String? selectedAccount;
String? selectedAgegroup;
String? selectedSubgroup;
bool isLoading = false;
bool isRefreshing = false;
String? error;
DateTime? lastUpdated;
ScheduleProvider(this._apiService);
Future<void> 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;
}
}
// Restore persisted filters for selected account
if (selectedAccount != null) {
final (cachedAgegroup, cachedSubgroup) =
await _cacheService.loadFilters(selectedAccount!);
selectedAgegroup = cachedAgegroup;
selectedSubgroup = cachedSubgroup;
}
notifyListeners();
} catch (e) {
error = 'Failed to load accounts: $e';
notifyListeners();
}
}
Future<void> loadCachedSchedule() async {
if (selectedAccount == null) return;
isLoading = true;
error = null;
notifyListeners();
try {
final cachedEvents = await _cacheService.loadEvents(selectedAccount!);
if (cachedEvents != null) {
events = cachedEvents.where((e) => e.eventType == 'Jeu').toList();
lastUpdated = await _cacheService.getLastUpdated(selectedAccount!);
} else {
events = [];
}
isLoading = false;
notifyListeners();
// Auto-refresh on first run if cache is empty
if (events.isEmpty) {
await refreshSchedule();
}
} catch (e) {
isLoading = false;
error = 'Failed to load cached schedule: $e';
notifyListeners();
}
}
Future<void> refreshSchedule() async {
if (selectedAccount == null) return;
isRefreshing = true;
error = null;
notifyListeners();
try {
final allEvents = await _apiService.getSchedule(selectedAccount!);
events = allEvents.where((e) => e.eventType == 'Jeu').toList();
// Cache for this account
await _cacheService.saveEvents(selectedAccount!, allEvents);
lastUpdated = DateTime.now();
} catch (e) {
error = 'Failed to refresh schedule: $e';
} finally {
isRefreshing = false;
notifyListeners();
}
}
Future<void> setAccount(String account) async {
selectedAccount = account;
await _authService.setAccount(account);
// Restore filters for this account
final (cachedAgegroup, cachedSubgroup) =
await _cacheService.loadFilters(account);
selectedAgegroup = cachedAgegroup;
selectedSubgroup = cachedSubgroup;
notifyListeners();
// Load cached schedule for this account, or auto-refresh if empty
await loadCachedSchedule();
}
Future<void> setAgegroup(String? agegroup) async {
selectedAgegroup = agegroup;
selectedSubgroup = null;
if (selectedAccount != null) {
await _cacheService.saveFilters(
selectedAccount!,
selectedAgegroup,
selectedSubgroup,
);
}
notifyListeners();
}
Future<void> setSubgroup(String? subgroup) async {
selectedSubgroup = subgroup;
if (selectedAccount != null) {
await _cacheService.saveFilters(
selectedAccount!,
selectedAgegroup,
selectedSubgroup,
);
}
notifyListeners();
}
Future<void> clearCache() async {
await _cacheService.clearAllCache();
}
List<Event> 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<String> get agegroups {
return events.map((e) => e.agegroup).toSet().toList()..sort();
}
List<String> 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) {
parts.removeLast(); // Remove opponent
return parts.join(' - ');
}
return name;
}
}
+152
View File
@@ -0,0 +1,152 @@
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<EventDetailScreen> createState() => _EventDetailScreenState();
}
class _EventDetailScreenState extends State<EventDetailScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<EventDetailProvider>().loadEventDetail(
widget.gameId,
widget.account,
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.eventTitle),
),
body: Consumer<EventDetailProvider>(
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}'),
),
);
}),
],
],
),
);
},
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _isLoading = false;
void _login() async {
setState(() => _isLoading = true);
try {
await context.read<AuthProvider>().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),
),
],
),
),
),
);
}
}
+240
View File
@@ -0,0 +1,240 @@
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<ScheduleScreen> createState() => _ScheduleScreenState();
}
class _ScheduleScreenState extends State<ScheduleScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final scheduleProvider = context.read<ScheduleProvider>();
scheduleProvider.loadAccounts().then((_) {
scheduleProvider.loadCachedSchedule();
});
});
}
void _logout() async {
await context.read<AuthProvider>().logout();
await context.read<ScheduleProvider>().clearCache();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
}
Future<void> _refresh() async {
await context.read<ScheduleProvider>().refreshSchedule();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('MyIce'),
actions: [
Consumer<AuthProvider>(
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.refresh),
onPressed: _refresh,
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: _logout,
),
],
),
body: Consumer<ScheduleProvider>(
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.refreshSchedule,
child: const Text('Reessayer'),
),
],
),
);
}
return Stack(
children: [
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
? Center(
child: schedule.events.isEmpty
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Aucun evenement disponible',
),
SizedBox(height: 8),
Text(
'Appuyez sur le bouton de rafraichissement pour charger les evenements',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
)
: const 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,
),
),
);
},
);
},
),
),
],
),
if (schedule.isRefreshing)
Positioned.fill(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: schedule.isRefreshing ? 1.0 : 0.0,
child: AbsorbPointer(
absorbing: true,
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: Center(
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 15,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
strokeWidth: 4,
),
),
const SizedBox(height: 16),
Text(
'Chargement...',
style: TextStyle(
color: Colors.grey[800],
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
],
),
),
),
),
),
),
),
],
);
},
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
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<List<Account>> getAccounts() async {
final response = await _dio.get('/accounts');
return (response.data as List)
.map((json) => Account.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<List<Event>> 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<String, dynamic>))
.toList();
}
Future<EventDetail> getGameDetail(String gameId, String account) async {
final response = await _dio.get('/game/$gameId', queryParameters: {'account': account});
return EventDetail.fromJson(response.data as Map<String, dynamic>);
}
Future<UserInfo> getUserInfo() async {
final response = await _dio.get('/userinfo');
return UserInfo.fromJson(response.data as Map<String, dynamic>);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter_web_auth_2/flutter_web_auth_2.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<String?> getStoredToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
Future<String?> getStoredAccount() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_accountKey);
}
Future<String?> login() async {
final result = await FlutterWebAuth2.authenticate(
url: '${ApiConfig.baseUrl}/login?redirect_uri=${Uri.encodeComponent('myice://callback')}',
callbackUrlScheme: 'myice',
);
final uri = Uri.parse(result);
// Check for authorization errors first
final error = uri.queryParameters['error'];
if (error != null) {
throw Exception('Authorization failed: $error');
}
// Extract token from fragment (#access_token=...)
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<void> setAccount(String account) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accountKey, account);
}
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_accountKey);
}
}
+85
View File
@@ -0,0 +1,85 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/event.dart';
class ScheduleCacheService {
String _eventsKey(String account) => 'cached_events_$account';
String _agegroupKey(String account) => 'filters_agegroup_$account';
String _subgroupKey(String account) => 'filters_subgroup_$account';
String _timestampKey(String account) => 'cached_events_timestamp_$account';
Future<void> saveEvents(String account, List<Event> events) async {
final prefs = await SharedPreferences.getInstance();
final jsonList = events.map((e) => e.toJson()).toList();
await prefs.setString(_eventsKey(account), jsonEncode(jsonList));
await prefs.setInt(
_timestampKey(account),
DateTime.now().millisecondsSinceEpoch,
);
}
Future<List<Event>?> loadEvents(String account) async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_eventsKey(account));
if (jsonString == null) return null;
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => Event.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
return null;
}
}
Future<DateTime?> getLastUpdated(String account) async {
final prefs = await SharedPreferences.getInstance();
final timestamp = prefs.getInt(_timestampKey(account));
if (timestamp == null) return null;
return DateTime.fromMillisecondsSinceEpoch(timestamp);
}
Future<void> saveFilters(
String account,
String? agegroup,
String? subgroup,
) async {
final prefs = await SharedPreferences.getInstance();
if (agegroup != null) {
await prefs.setString(_agegroupKey(account), agegroup);
} else {
await prefs.remove(_agegroupKey(account));
}
if (subgroup != null) {
await prefs.setString(_subgroupKey(account), subgroup);
} else {
await prefs.remove(_subgroupKey(account));
}
}
Future<(String?, String?)> loadFilters(String account) async {
final prefs = await SharedPreferences.getInstance();
return (
prefs.getString(_agegroupKey(account)),
prefs.getString(_subgroupKey(account)),
);
}
Future<void> clearCache(String account) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_eventsKey(account));
await prefs.remove(_timestampKey(account));
await prefs.remove(_agegroupKey(account));
await prefs.remove(_subgroupKey(account));
}
Future<void> clearAllCache() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys().where((k) => k.startsWith('cached_events_') ||
k.startsWith('filters_') ||
k.startsWith('cached_events_timestamp_'));
for (final key in keys) {
await prefs.remove(key);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
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,
),
],
),
),
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class FilterDropdown extends StatelessWidget {
final String label;
final String? value;
final List<String> items;
final ValueChanged<String?>? onChanged;
const FilterDropdown({
super.key,
required this.label,
this.value,
required this.items,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
initialValue: value,
items: items.map((item) {
return DropdownMenuItem(
value: item,
child: Text(item),
);
}).toList(),
onChanged: onChanged,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class LoadingIndicator extends StatelessWidget {
const LoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator(),
);
}
}