Integration fonctionnalités V1 ( resas + gestion machines

This commit is contained in:
bastien
2026-07-03 19:02:48 +02:00
parent 20f383dd62
commit bf191d6396
46 changed files with 4691 additions and 654 deletions
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Fond et carte pour les écrans d'authentification.␍
class AuthScaffold extends StatelessWidget {
const AuthScaffold({
super.key,
required this.child,
this.showBackButton = false,
});
final Widget child;
final bool showBackButton;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.gradientSoft),
child: SafeArea(
child: Column(
children: [
if (showBackButton)
Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.arrow_back),
),
),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: child,
),
),
),
],
),
),
),
);
}
}
/// Carte blanche pour formulaires auth.␍
class AuthFormCard extends StatelessWidget {
const AuthFormCard({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24),
child: child,
),
),
);
}
}
/// Logo et titre auth.␍
class AuthHeader extends StatelessWidget {
const AuthHeader({super.key, required this.subtitle});
final String subtitle;
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: AppColors.gradientPrimary,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.local_laundry_service_outlined, size: 32, color: Colors.white),
),
const SizedBox(height: 16),
Text(
'Laverie Connectée',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(subtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 24),
],
);
}
}
+155 -34
View File
@@ -1,9 +1,19 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../booking/data/booking_repository.dart';
import '../../wallet/data/wallet_repository.dart';
import '../../wash/data/wash_repository.dart';
import 'auth_widgets.dart';
/// Écran de connexion utilisateur.
class LoginScreen extends ConsumerStatefulWidget {
@@ -17,6 +27,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'marie.dupont@demo.local');
final _passwordController = TextEditingController(text: 'password');
bool _isCheckingHealth = false;
String? _healthResult;
bool _healthHasError = false;
@override
void dispose() {
@@ -25,6 +38,79 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
super.dispose();
}
Future<void> _checkHealth() async {
setState(() {
_isCheckingHealth = true;
_healthResult = null;
_healthHasError = false;
});
final apiClient = ref.read(apiClientProvider);
final lines = <String>[
'URL de base : ${apiClient.baseUrl}',
'',
];
var hasError = false;
for (final entry in [
('API', ApiEndpoints.health),
('Base de données', ApiEndpoints.healthDb),
]) {
final label = entry.$1;
final path = entry.$2;
final url = '${apiClient.baseUrl}$path';
try {
final response = await apiClient.get(path);
lines.add('$label : OK (${response.statusCode})');
lines.add('URL : $url');
lines.add(_formatResponse(response.data));
} on DioException catch (error) {
hasError = true;
lines.add('$label : Erreur');
lines.add('URL : $url');
lines.add(_formatDioError(error));
}
lines.add('');
}
if (mounted) {
setState(() {
_isCheckingHealth = false;
_healthHasError = hasError;
_healthResult = lines.join('\n').trim();
});
}
}
String _formatResponse(dynamic data) {
if (data is Map || data is List) {
return const JsonEncoder.withIndent(' ').convert(data);
}
return data?.toString() ?? '';
}
String _formatDioError(DioException error) {
final response = error.response;
if (response != null) {
final body = response.data;
if (body is Map || body is List) {
return 'HTTP ${response.statusCode}\n${_formatResponse(body)}';
}
return 'HTTP ${response.statusCode}: $body';
}
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'Délai d\'attente dépassé',
DioExceptionType.connectionError =>
'Connexion impossible (${error.message ?? 'réseau injoignable'})',
_ => error.message ?? 'Erreur réseau',
};
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
@@ -34,6 +120,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
);
if (success && mounted) {
ref.invalidate(washesProvider);
ref.invalidate(bookingsProvider);
ref.invalidate(walletProvider);
ref.invalidate(walletTransactionsProvider);
context.go(AppRoutes.home);
}
}
@@ -42,32 +132,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
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(
return AuthScaffold(
child: AuthFormCard(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AuthHeader(subtitle: 'Connectez-vous pour réserver et laver'),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
@@ -98,10 +171,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
if (authState.error != null) ...[
const SizedBox(height: 12),
Text(
authState.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.error.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
authState.error!,
style: const TextStyle(color: AppColors.error),
),
),
],
),
),
],
const SizedBox(height: 24),
@@ -111,7 +199,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('Se connecter'),
),
@@ -120,12 +208,45 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
onPressed: () => context.push(AppRoutes.register),
child: const Text('Créer un compte'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _isCheckingHealth ? null : _checkHealth,
icon: _isCheckingHealth
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.monitor_heart_outlined, size: 18),
label: const Text('Vérifier l\'API'),
),
if (_healthResult != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: (_healthHasError ? AppColors.error : AppColors.primary)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (_healthHasError ? AppColors.error : AppColors.primary)
.withValues(alpha: 0.3),
),
),
child: SelectableText(
_healthResult!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: _healthHasError ? AppColors.error : AppColors.primaryDark,
),
),
),
],
],
),
),
),
),
),
);
);
}
}
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import 'auth_widgets.dart';
/// Écran d'inscription utilisateur.
class RegisterScreen extends ConsumerStatefulWidget {
@@ -53,72 +55,82 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
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,
return AuthScaffold(
showBackButton: true,
child: AuthFormCard(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AuthHeader(subtitle: 'Créez votre compte en quelques secondes'),
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Prénom',
prefixIcon: Icon(Icons.person_outline_rounded),
),
validator: (v) => v == null || v.isEmpty ? 'Prénom requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Nom',
prefixIcon: Icon(Icons.badge_outlined),
),
validator: (v) => v == null || v.isEmpty ? 'Nom requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
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)',
prefixIcon: Icon(Icons.phone_outlined),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (v) {
if (v == null || v.length < 8) {
return 'Minimum 8 caractères';
}
return null;
},
),
if (authState.error != 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'),
),
Text(authState.error!, style: const TextStyle(color: AppColors.error)),
],
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: authState.isLoading ? null : _submit,
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('S\'inscrire'),
),
],
),
),
),
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Écran affiché pendant la restauration de session au démarrage.
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(gradient: AppColors.gradientSoft),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: AppColors.gradientPrimary,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.local_laundry_service_outlined, size: 36, color: Colors.white),
),
const SizedBox(height: 20),
Text(
'Laverie Connectée',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
const CircularProgressIndicator(),
],
),
),
),
);
}
}