initial commit

This commit is contained in:
bastien
2026-06-28 12:29:06 +02:00
parent 79d5b012a9
commit 20f383dd62
93 changed files with 4269 additions and 1 deletions
+53
View File
@@ -0,0 +1,53 @@
/// Modèle utilisateur authentifié.
class AuthUser {
const AuthUser({
required this.uuid,
required this.firstName,
required this.lastName,
required this.email,
this.phone,
this.locale = 'fr',
});
final String uuid;
final String firstName;
final String lastName;
final String email;
final String? phone;
final String locale;
String get fullName => '$firstName $lastName'.trim();
factory AuthUser.fromJson(Map<String, dynamic> json) {
return AuthUser(
uuid: json['uuid'] as String,
firstName: json['first_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '',
email: json['email'] as String,
phone: json['phone'] as String?,
locale: json['locale'] as String? ?? 'fr',
);
}
}
/// Réponse d'authentification (login / register / refresh).
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.refreshToken,
this.user,
});
final String accessToken;
final String refreshToken;
final AuthUser? user;
factory AuthTokens.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] as Map<String, dynamic>?;
return AuthTokens(
accessToken: json['access_token'] as String? ?? json['token'] as String,
refreshToken: json['refresh_token'] as String,
user: userJson != null ? AuthUser.fromJson(userJson) : null,
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
/// Écran de connexion utilisateur.
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'marie.dupont@demo.local');
final _passwordController = TextEditingController(text: 'password');
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final success = await ref.read(authProvider.notifier).login(
_emailController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
context.go(AppRoutes.home);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(Icons.local_laundry_service, size: 72, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(
'Laverie Connectée',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Connectez-vous pour réserver et laver',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email requis';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Mot de passe requis';
}
return null;
},
),
if (authState.error != null) ...[
const SizedBox(height: 12),
Text(
authState.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: authState.isLoading ? null : _submit,
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Se connecter'),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => context.push(AppRoutes.register),
child: const Text('Créer un compte'),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
/// Écran d'inscription utilisateur.
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final success = await ref.read(authProvider.notifier).register(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
phone: _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim(),
);
if (success && mounted) {
context.go(AppRoutes.home);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Scaffold(
appBar: AppBar(title: const Text('Inscription')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(labelText: 'Prénom'),
validator: (v) => v == null || v.isEmpty ? 'Prénom requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(labelText: 'Nom'),
validator: (v) => v == null || v.isEmpty ? 'Nom requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
validator: (v) => v == null || v.isEmpty ? 'Email requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: 'Téléphone (optionnel)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Mot de passe'),
validator: (v) {
if (v == null || v.length < 8) {
return 'Minimum 8 caractères';
}
return null;
},
),
if (authState.error != null) ...[
const SizedBox(height: 12),
Text(
authState.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: authState.isLoading ? null : _submit,
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('S\'inscrire'),
),
],
),
),
),
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
/// Modèle réservation de créneau machine.
class Booking {
const Booking({
required this.uuid,
required this.machineUuid,
required this.machineName,
required this.slotStart,
required this.slotEnd,
required this.status,
this.bookingFee = 0,
});
final String uuid;
final String machineUuid;
final String machineName;
final DateTime? slotStart;
final DateTime? slotEnd;
final String status;
final double bookingFee;
factory Booking.fromJson(Map<String, dynamic> json) {
final machine = json['machine'] as Map<String, dynamic>?;
return Booking(
uuid: json['uuid'] as String,
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
machineName: machine?['name'] as String? ?? 'Machine',
slotStart: json['slot_start'] != null
? DateTime.tryParse(json['slot_start'] as String)
: null,
slotEnd: json['slot_end'] != null
? DateTime.tryParse(json['slot_end'] as String)
: null,
status: json['status'] as String? ?? 'pending',
bookingFee: (json['booking_fee'] as num?)?.toDouble() ?? 0,
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../domain/booking.dart';
/// Fournisseur des réservations de l'utilisateur connecté.
final bookingsProvider = FutureProvider<List<Booking>>((ref) async {
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.bookings);
final data = response.data;
List<dynamic> list;
if (data is List<dynamic>) {
list = data;
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
list = data['data'] as List<dynamic>;
} else {
list = [];
}
return list
.map((json) => Booking.fromJson(json as Map<String, dynamic>))
.toList();
});
/// Écran listant les réservations de l'utilisateur.
class BookingsScreen extends ConsumerWidget {
const BookingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bookingsAsync = ref.watch(bookingsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Mes réservations')),
body: bookingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Erreur : $error'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.invalidate(bookingsProvider),
child: const Text('Réessayer'),
),
],
),
),
data: (bookings) {
if (bookings.isEmpty) {
return const Center(
child: Text('Aucune réservation.\nRéservez un créneau depuis une laverie.'),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(bookingsProvider),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: bookings.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final booking = bookings[index];
return Card(
child: ListTile(
leading: const Icon(Icons.event),
title: Text(booking.machineName),
subtitle: Text(
booking.slotStart != null
? DateFormat('dd/MM/yyyy HH:mm').format(booking.slotStart!)
: 'Créneau à confirmer',
),
trailing: Chip(label: Text(booking.status)),
),
);
},
),
);
},
),
);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../domain/establishment.dart';
/// Dépôt de données pour les établissements et machines.
class EstablishmentRepository {
EstablishmentRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<Establishment>> fetchEstablishments() async {
final response = await _apiClient.get(ApiEndpoints.establishments);
final data = _extractList(response.data);
return data
.map((json) => Establishment.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<Establishment> fetchEstablishment(String uuid) async {
final response = await _apiClient.get(ApiEndpoints.establishment(uuid));
final json = _extractObject(response.data);
return Establishment.fromJson(json);
}
List<dynamic> _extractList(dynamic data) {
if (data is List<dynamic>) return data;
if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
return data['data'] as List<dynamic>;
}
return [];
}
Map<String, dynamic> _extractObject(dynamic data) {
if (data is Map<String, dynamic>) {
if (data['data'] is Map<String, dynamic>) {
return data['data'] as Map<String, dynamic>;
}
return data;
}
throw StateError('Réponse API inattendue');
}
}
final establishmentRepositoryProvider = Provider<EstablishmentRepository>((ref) {
return EstablishmentRepository(ref.watch(apiClientProvider));
});
final establishmentsProvider = FutureProvider<List<Establishment>>((ref) async {
return ref.watch(establishmentRepositoryProvider).fetchEstablishments();
});
final establishmentDetailProvider =
FutureProvider.family<Establishment, String>((ref, uuid) async {
return ref.watch(establishmentRepositoryProvider).fetchEstablishment(uuid);
});
@@ -0,0 +1,110 @@
/// Modèle établissement (laverie).
class Establishment {
const Establishment({
required this.uuid,
required this.name,
required this.address,
this.city,
this.zipCode,
this.latitude,
this.longitude,
this.isActive = true,
this.machines = const [],
});
final String uuid;
final String name;
final String address;
final String? city;
final String? zipCode;
final double? latitude;
final double? longitude;
final bool isActive;
final List<Machine> machines;
String get fullAddress {
final parts = [address, zipCode, city].where((p) => p != null && p.isNotEmpty);
return parts.join(', ');
}
factory Establishment.fromJson(Map<String, dynamic> json) {
final machinesJson = json['machines'] as List<dynamic>? ?? [];
return Establishment(
uuid: json['uuid'] as String,
name: json['name'] as String,
address: json['address'] as String? ?? '',
city: json['city'] as String?,
zipCode: json['zip_code'] as String?,
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
isActive: json['is_active'] as bool? ?? true,
machines: machinesJson
.map((m) => Machine.fromJson(m as Map<String, dynamic>))
.toList(),
);
}
}
/// Modèle machine (lave-linge / sèche-linge).
class Machine {
const Machine({
required this.uuid,
required this.name,
required this.type,
required this.status,
this.qrCode,
});
final String uuid;
final String name;
final String type;
final String status;
final String? qrCode;
bool get isAvailable => status == 'available';
String get typeLabel {
switch (type) {
case 'washer_small':
return 'Lave-linge petit';
case 'washer_large':
return 'Lave-linge grand';
case 'dryer_small':
return 'Sèche-linge petit';
case 'dryer_large':
return 'Sèche-linge grand';
default:
return type;
}
}
String get statusLabel {
switch (status) {
case 'available':
return 'Disponible';
case 'reserved':
return 'Réservée';
case 'running':
return 'En cours';
case 'maintenance':
return 'Maintenance';
case 'offline':
return 'Hors ligne';
case 'error':
return 'Erreur';
default:
return status;
}
}
factory Machine.fromJson(Map<String, dynamic> json) {
return Machine(
uuid: json['uuid'] as String,
name: json['name'] as String,
type: json['type'] as String,
status: json['status'] as String,
qrCode: json['qr_code'] as String?,
);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../establishments/data/establishment_repository.dart';
import '../../establishments/domain/establishment.dart';
/// Écran de détail d'un établissement avec liste des machines.
class EstablishmentDetailScreen extends ConsumerWidget {
const EstablishmentDetailScreen({
super.key,
required this.establishmentUuid,
});
final String establishmentUuid;
@override
Widget build(BuildContext context, WidgetRef ref) {
final establishmentAsync =
ref.watch(establishmentDetailProvider(establishmentUuid));
return Scaffold(
appBar: AppBar(title: const Text('Détail laverie')),
body: establishmentAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Erreur : $error'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.invalidate(establishmentDetailProvider(establishmentUuid)),
child: const Text('Réessayer'),
),
],
),
),
data: (establishment) => _EstablishmentBody(establishment: establishment),
),
);
}
}
class _EstablishmentBody extends StatelessWidget {
const _EstablishmentBody({required this.establishment});
final Establishment establishment;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
establishment.name,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(establishment.fullAddress),
const SizedBox(height: 24),
Text(
'Machines (${establishment.machines.length})',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (establishment.machines.isEmpty)
const Text('Aucune machine disponible')
else
...establishment.machines.map((machine) => _MachineTile(machine: machine)),
],
);
}
}
class _MachineTile extends StatelessWidget {
const _MachineTile({required this.machine});
final Machine machine;
Color _statusColor(BuildContext context) {
switch (machine.status) {
case 'available':
return Colors.green;
case 'running':
return Colors.blue;
case 'reserved':
return Colors.orange;
case 'maintenance':
case 'offline':
return Colors.red;
default:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(
machine.type.startsWith('washer') ? Icons.water_drop : Icons.air,
color: _statusColor(context),
),
title: Text(machine.name),
subtitle: Text('${machine.typeLabel}${machine.statusLabel}'),
trailing: machine.isAvailable
? const Chip(label: Text('Libre'))
: null,
),
);
}
}
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../establishments/data/establishment_repository.dart';
/// Écran d'accueil — liste des laveries à proximité.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final establishmentsAsync = ref.watch(establishmentsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Laveries'),
actions: [
IconButton(
icon: const Icon(Icons.account_balance_wallet_outlined),
onPressed: () => context.push(AppRoutes.wallet),
),
IconButton(
icon: const Icon(Icons.person_outline),
onPressed: () => context.push(AppRoutes.profile),
),
],
),
body: establishmentsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(
'Impossible de charger les laveries.\nVérifiez que l\'API est démarrée.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(establishmentsProvider),
child: const Text('Réessayer'),
),
],
),
),
),
data: (establishments) {
if (establishments.isEmpty) {
return const Center(child: Text('Aucune laverie disponible'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(establishmentsProvider),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: establishments.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final establishment = establishments[index];
return Card(
child: ListTile(
leading: CircleAvatar(
child: Text(establishment.name.substring(0, 1)),
),
title: Text(establishment.name),
subtitle: Text(establishment.fullAddress),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/establishments/${establishment.uuid}'),
),
);
},
),
);
},
),
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go(AppRoutes.home);
case 1:
context.push(AppRoutes.bookings);
case 2:
context.push(AppRoutes.washes);
case 3:
context.push(AppRoutes.wallet);
}
},
destinations: const [
NavigationDestination(icon: Icon(Icons.store), label: 'Laveries'),
NavigationDestination(icon: Icon(Icons.event), label: 'Réservations'),
NavigationDestination(icon: Icon(Icons.local_laundry_service), label: 'Lavages'),
NavigationDestination(icon: Icon(Icons.wallet), label: 'Wallet'),
],
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
/// Écran profil utilisateur et déconnexion.
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final user = authState.user;
return Scaffold(
appBar: AppBar(title: const Text('Mon profil')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
CircleAvatar(
radius: 40,
child: Text(
user != null && user.firstName.isNotEmpty
? user.firstName.substring(0, 1).toUpperCase()
: '?',
style: const TextStyle(fontSize: 32),
),
),
const SizedBox(height: 16),
Text(
user?.fullName ?? 'Utilisateur',
style: Theme.of(context).textTheme.titleLarge,
),
if (user?.email != null) ...[
const SizedBox(height: 4),
Text(user!.email),
],
],
),
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.language),
title: const Text('Langue'),
subtitle: Text(user?.locale ?? 'fr'),
),
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Notifications'),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Préférences — à implémenter')),
);
},
),
const Divider(),
ListTile(
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
title: Text(
'Se déconnecter',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go(AppRoutes.login);
}
},
),
],
),
);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../domain/wallet.dart';
/// Dépôt de données pour le portefeuille électronique.
class WalletRepository {
WalletRepository(this._apiClient);
final ApiClient _apiClient;
Future<Wallet> fetchWallet() async {
final response = await _apiClient.get(ApiEndpoints.wallet);
final json = _extractObject(response.data);
return Wallet.fromJson(json);
}
Future<List<WalletTransaction>> fetchTransactions() async {
final response = await _apiClient.get(ApiEndpoints.walletTransactions);
final list = _extractList(response.data);
return list
.map((json) => WalletTransaction.fromJson(json as Map<String, dynamic>))
.toList();
}
List<dynamic> _extractList(dynamic data) {
if (data is List<dynamic>) return data;
if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
return data['data'] as List<dynamic>;
}
return [];
}
Map<String, dynamic> _extractObject(dynamic data) {
if (data is Map<String, dynamic>) {
if (data['data'] is Map<String, dynamic>) {
return data['data'] as Map<String, dynamic>;
}
return data;
}
throw StateError('Réponse API inattendue');
}
}
final walletRepositoryProvider = Provider<WalletRepository>((ref) {
return WalletRepository(ref.watch(apiClientProvider));
});
final walletProvider = FutureProvider<Wallet>((ref) async {
return ref.watch(walletRepositoryProvider).fetchWallet();
});
final walletTransactionsProvider =
FutureProvider<List<WalletTransaction>>((ref) async {
return ref.watch(walletRepositoryProvider).fetchTransactions();
});
+49
View File
@@ -0,0 +1,49 @@
/// Modèle portefeuille électronique.
class Wallet {
const Wallet({
required this.currency,
required this.currentBalance,
this.status = 'active',
});
final String currency;
final double currentBalance;
final String status;
factory Wallet.fromJson(Map<String, dynamic> json) {
return Wallet(
currency: json['currency'] as String? ?? 'EUR',
currentBalance: (json['current_balance'] as num?)?.toDouble() ?? 0,
status: json['status'] as String? ?? 'active',
);
}
}
/// Mouvement sur le portefeuille.
class WalletTransaction {
const WalletTransaction({
required this.uuid,
required this.type,
required this.amount,
required this.balanceAfter,
required this.createdAt,
});
final String uuid;
final String type;
final double amount;
final double balanceAfter;
final DateTime? createdAt;
factory WalletTransaction.fromJson(Map<String, dynamic> json) {
return WalletTransaction(
uuid: json['uuid'] as String,
type: json['type'] as String,
amount: (json['amount'] as num?)?.toDouble() ?? 0,
balanceAfter: (json['balance_after'] as num?)?.toDouble() ?? 0,
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
: null,
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../data/wallet_repository.dart';
/// Écran du portefeuille électronique — solde et historique.
class WalletScreen extends ConsumerWidget {
const WalletScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final walletAsync = ref.watch(walletProvider);
final transactionsAsync = ref.watch(walletTransactionsProvider);
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '');
return Scaffold(
appBar: AppBar(title: const Text('Mon portefeuille')),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(walletProvider);
ref.invalidate(walletTransactionsProvider);
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
walletAsync.when(
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Erreur solde : $error'),
),
),
data: (wallet) => Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
'Solde disponible',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
currencyFormat.format(wallet.currentBalance),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
const SizedBox(height: 24),
Text(
'Historique',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
transactionsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Text('Erreur historique : $error'),
data: (transactions) {
if (transactions.isEmpty) {
return const Text('Aucune transaction pour le moment');
}
return Column(
children: transactions.map((tx) {
final isCredit = tx.type == 'credit' || tx.type == 'refund';
return Card(
child: ListTile(
leading: Icon(
isCredit ? Icons.add_circle_outline : Icons.remove_circle_outline,
color: isCredit ? Colors.green : Colors.red,
),
title: Text(tx.type),
subtitle: tx.createdAt != null
? Text(DateFormat('dd/MM/yyyy HH:mm').format(tx.createdAt!))
: null,
trailing: Text(
'${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isCredit ? Colors.green : Colors.red,
),
),
),
);
}).toList(),
);
},
),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Rechargement — à connecter à l\'API')),
);
},
icon: const Icon(Icons.add),
label: const Text('Recharger'),
),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
/// Modèle lavage (cycle en cours ou terminé).
class Wash {
const Wash({
required this.uuid,
required this.machineUuid,
required this.machineName,
required this.status,
required this.cost,
this.startedAt,
this.endedAt,
this.durationMinutes,
});
final String uuid;
final String machineUuid;
final String machineName;
final String status;
final double cost;
final DateTime? startedAt;
final DateTime? endedAt;
final int? durationMinutes;
factory Wash.fromJson(Map<String, dynamic> json) {
final machine = json['machine'] as Map<String, dynamic>?;
return Wash(
uuid: json['uuid'] as String,
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
machineName: machine?['name'] as String? ?? 'Machine',
status: json['status'] as String? ?? 'pending_start',
cost: (json['cost'] as num?)?.toDouble() ?? 0,
startedAt: json['started_at'] != null
? DateTime.tryParse(json['started_at'] as String)
: null,
endedAt: json['ended_at'] != null
? DateTime.tryParse(json['ended_at'] as String)
: null,
durationMinutes: json['duration_minutes'] as int?,
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../domain/wash.dart';
/// Fournisseur de l'historique des lavages.
final washesProvider = FutureProvider<List<Wash>>((ref) async {
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.washes);
final data = response.data;
List<dynamic> list;
if (data is List<dynamic>) {
list = data;
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
list = data['data'] as List<dynamic>;
} else {
list = [];
}
return list.map((json) => Wash.fromJson(json as Map<String, dynamic>)).toList();
});
/// Écran historique et démarrage de lavage.
class WashScreen extends ConsumerWidget {
const WashScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final washesAsync = ref.watch(washesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Mes lavages')),
body: washesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Erreur : $error'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.invalidate(washesProvider),
child: const Text('Réessayer'),
),
],
),
),
data: (washes) {
if (washes.isEmpty) {
return const Center(
child: Text(
'Aucun lavage enregistré.\nScannez un QR code pour démarrer un cycle.',
textAlign: TextAlign.center,
),
);
}
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '');
return RefreshIndicator(
onRefresh: () async => ref.invalidate(washesProvider),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: washes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final wash = washes[index];
return Card(
child: ListTile(
leading: const Icon(Icons.local_laundry_service),
title: Text(wash.machineName),
subtitle: wash.startedAt != null
? Text(DateFormat('dd/MM/yyyy HH:mm').format(wash.startedAt!))
: null,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(currencyFormat.format(wash.cost)),
Text(wash.status, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
},
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Scan QR — à connecter à l\'API /washes/start')),
);
},
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Scanner'),
),
);
}
}