initial import
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
class ApiConfig {
|
||||
static const String baseUrl = 'https://myice.parano.ch';
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}'),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user