diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index d10062e..be288bd 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -9,15 +10,25 @@ class AdminDashboardScreen extends StatefulWidget { const AdminDashboardScreen({super.key}); @override - _AdminDashboardScreenState createState() => _AdminDashboardScreenState(); + State createState() => _AdminDashboardScreenState(); } class _AdminDashboardScreenState extends State { - int selectedIndex = 0; + /// 0 = Gestion des utilisateurs, 1 = Paramètres + int mainTabIndex = 0; - void onTabChange(int index) { + /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs + int subIndex = 0; + + void onMainTabChange(int index) { setState(() { - selectedIndex = index; + mainTabIndex = index; + }); + } + + void onSubTabChange(int index) { + setState(() { + subIndex = index; }); } @@ -33,13 +44,18 @@ class _AdminDashboardScreenState extends State { ), ), child: DashboardAppBarAdmin( - selectedIndex: selectedIndex, - onTabChange: onTabChange, + selectedIndex: mainTabIndex, + onTabChange: onMainTabChange, ), ), ), body: Column( children: [ + if (mainTabIndex == 0) + DashboardUserManagementSubBar( + selectedSubIndex: subIndex, + onSubTabChange: onSubTabChange, + ), Expanded( child: _getBody(), ), @@ -50,17 +66,20 @@ class _AdminDashboardScreenState extends State { } Widget _getBody() { - switch (selectedIndex) { + if (mainTabIndex == 1) { + return const ParametresPanel(); + } + switch (subIndex) { case 0: - return const GestionnaireManagementWidget(); + return const GestionnaireManagementWidget(); case 1: return const ParentManagementWidget(); case 2: return const AssistanteMaternelleManagementWidget(); case 3: - return const Center(child: Text("👨‍💼 Administrateurs")); + return const Center(child: Text('👨‍💼 Administrateurs')); default: - return const Center(child: Text("Page non trouvée")); + return const Center(child: Text('Page non trouvée')); } } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index bd157f8..831e911 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -14,6 +14,13 @@ class ApiConfig { static const String userProfile = '/users/profile'; static const String userChildren = '/users/children'; + // Configuration (admin) + static const String configuration = '/configuration'; + static const String configurationSetupStatus = '/configuration/setup/status'; + static const String configurationSetupComplete = '/configuration/setup/complete'; + static const String configurationTestSmtp = '/configuration/test-smtp'; + static const String configurationBulk = '/configuration/bulk'; + // Dashboard endpoints static const String dashboard = '/dashboard'; static const String events = '/events'; diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart new file mode 100644 index 0000000..8f1c905 --- /dev/null +++ b/frontend/lib/services/configuration_service.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api/api_config.dart'; +import 'api/tokenService.dart'; + +/// Réponse GET /configuration (liste complète) +class ConfigItem { + final String cle; + final String? valeur; + final String type; + final String? categorie; + final String? description; + + ConfigItem({ + required this.cle, + this.valeur, + required this.type, + this.categorie, + this.description, + }); + + factory ConfigItem.fromJson(Map json) { + return ConfigItem( + cle: json['cle'] as String, + valeur: json['valeur'] as String?, + type: json['type'] as String? ?? 'string', + categorie: json['categorie'] as String?, + description: json['description'] as String?, + ); + } +} + +/// Réponse GET /configuration/:category (objet clé -> { value, type, description }) +class ConfigValueItem { + final dynamic value; + final String type; + final String? description; + + ConfigValueItem({required this.value, required this.type, this.description}); + + factory ConfigValueItem.fromJson(Map json) { + return ConfigValueItem( + value: json['value'], + type: json['type'] as String? ?? 'string', + description: json['description'] as String?, + ); + } +} + +class ConfigurationService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + /// GET /api/v1/configuration/setup/status + static Future getSetupStatus() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupStatus}'), + headers: await _headers(), + ); + if (response.statusCode != 200) return true; + final data = jsonDecode(response.body); + return data['data']?['setupCompleted'] as bool? ?? true; + } + + /// GET /api/v1/configuration (toutes les configs) + static Future> getAll() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', + ); + } + final data = jsonDecode(response.body); + final list = data['data'] as List? ?? []; + return list.map((e) => ConfigItem.fromJson(e as Map)).toList(); + } + + /// GET /api/v1/configuration/:category + static Future> getByCategory(String category) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}/$category'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', + ); + } + final data = jsonDecode(response.body); + final map = data['data'] as Map? ?? {}; + return map.map((k, v) => MapEntry(k, ConfigValueItem.fromJson(v as Map))); + } + + /// PATCH /api/v1/configuration/bulk + static Future updateBulk(Map body) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationBulk}'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map; + throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); + } + } + + /// POST /api/v1/configuration/test-smtp + static Future testSmtp(String testEmail) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationTestSmtp}'), + headers: await _headers(), + body: jsonEncode({'testEmail': testEmail}), + ); + final data = jsonDecode(response.body) as Map; + if (response.statusCode == 200 && data['success'] == true) { + return data['message'] as String? ?? 'Test SMTP réussi.'; + } + throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + } + + /// POST /api/v1/configuration/setup/complete (après première config) + static Future completeSetup() async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map; + throw Exception(err['message'] ?? 'Erreur finalisation configuration'); + } + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 6f63760..d19030a 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,47 +1,47 @@ import 'package:flutter/material.dart'; +/// Barre principale du dashboard admin : 2 onglets (Gestion des utilisateurs | Paramètres) + infos utilisateur. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; - const DashboardAppBarAdmin({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key); + const DashboardAppBarAdmin({ + Key? key, + required this.selectedIndex, + required this.onTabChange, + }) : super(key: key); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10); @override Widget build(BuildContext context) { - final isMobile = MediaQuery.of(context).size.width < 768; return AppBar( elevation: 0, automaticallyImplyLeading: false, title: Row( children: [ - SizedBox(width: MediaQuery.of(context).size.width * 0.19), - const Text( - "P'tit Pas", - style: TextStyle( - color: Color(0xFF9CC5C0), - fontWeight: FontWeight.bold, - fontSize: 18, + const SizedBox(width: 24), + Image.asset( + 'assets/images/logo.png', + height: 40, + fit: BoxFit.contain, + ), + Expanded( + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildNavItem(context, 'Gestion des utilisateurs', 0), + const SizedBox(width: 24), + _buildNavItem(context, 'Paramètres', 1), + ], + ), ), ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), - - // Navigation principale - _buildNavItem(context, 'Gestionnaires', 0), - const SizedBox(width: 24), - _buildNavItem(context, 'Parents', 1), - const SizedBox(width: 24), - _buildNavItem(context, 'Assistantes maternelles', 2), - const SizedBox(width: 24), - _buildNavItem(context, 'Administrateurs', 3), ], ), - actions: isMobile - ? [_buildMobileMenu(context)] - : [ - // Nom de l'utilisateur + actions: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Center( @@ -55,8 +55,6 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ), ), ), - - // Bouton déconnexion Padding( padding: const EdgeInsets.only(right: 16), child: TextButton( @@ -72,51 +70,30 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: const Text('Se déconnecter'), ), ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), ], ); } Widget _buildNavItem(BuildContext context, String title, int index) { - final bool isActive = index == selectedIndex; - return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, + final bool isActive = index == selectedIndex; + return InkWell( + onTap: () => onTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), ), ), - ), - ); -} - - - Widget _buildMobileMenu(BuildContext context) { - return PopupMenuButton( - icon: const Icon(Icons.menu, color: Colors.white), - onSelected: (value) { - if (value == 4) { - _handleLogout(context); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 0, child: Text("Gestionnaires")), - const PopupMenuItem(value: 1, child: Text("Parents")), - const PopupMenuItem(value: 2, child: Text("Assistantes maternelles")), - const PopupMenuItem(value: 3, child: Text("Administrateurs")), - const PopupMenuDivider(), - const PopupMenuItem(value: 4, child: Text("Se déconnecter")), - ], ); } @@ -142,4 +119,65 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ), ); } -} \ No newline at end of file +} + +/// Sous-barre affichée quand "Gestion des utilisateurs" est actif : 4 onglets sans infos utilisateur. +class DashboardUserManagementSubBar extends StatelessWidget { + final int selectedSubIndex; + final ValueChanged onSubTabChange; + + const DashboardUserManagementSubBar({ + Key? key, + required this.selectedSubIndex, + required this.onSubTabChange, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSubNavItem(context, 'Gestionnaires', 0), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Parents', 1), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Assistantes maternelles', 2), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Administrateurs', 3), + ], + ), + ), + ); + } + + Widget _buildSubNavItem(BuildContext context, String title, int index) { + final bool isActive = index == selectedSubIndex; + return InkWell( + onTap: () => onSubTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black87, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 13, + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart new file mode 100644 index 0000000..c50ecdd --- /dev/null +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/configuration_service.dart'; + +/// Panneau Paramètres / Configuration (ticket #15) : 3 sections sur une page. +class ParametresPanel extends StatefulWidget { + const ParametresPanel({super.key}); + + @override + State createState() => _ParametresPanelState(); +} + +class _ParametresPanelState extends State { + final _formKey = GlobalKey(); + bool _isLoading = true; + String? _loadError; + bool _isSaving = false; + String? _message; + + final Map _controllers = {}; + bool _smtpSecure = false; + bool _smtpAuthRequired = false; + + @override + void initState() { + super.initState(); + _createControllers(); + _loadConfiguration(); + } + + void _createControllers() { + final keys = [ + 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', + 'email_from_name', 'email_from_address', + 'app_name', 'app_url', 'app_logo_url', + 'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', + ]; + for (final k in keys) { + _controllers[k] = TextEditingController(); + } + } + + Future _loadConfiguration() async { + setState(() { + _isLoading = true; + _loadError = null; + }); + try { + final list = await ConfigurationService.getAll(); + if (!mounted) return; + for (final item in list) { + final c = _controllers[item.cle]; + if (c != null && item.valeur != null && item.valeur != '***********') { + c.text = item.valeur!; + } + if (item.cle == 'smtp_secure') { + _smtpSecure = item.valeur == 'true'; + } + if (item.cle == 'smtp_auth_required') { + _smtpAuthRequired = item.valeur == 'true'; + } + } + setState(() { + _isLoading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _loadError = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + @override + void dispose() { + for (final c in _controllers.values) { + c.dispose(); + } + super.dispose(); + } + + Map _buildPayload() { + final payload = {}; + payload['smtp_host'] = _controllers['smtp_host']!.text.trim(); + final port = int.tryParse(_controllers['smtp_port']!.text.trim()); + if (port != null) payload['smtp_port'] = port; + payload['smtp_secure'] = _smtpSecure; + payload['smtp_auth_required'] = _smtpAuthRequired; + payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); + final pwd = _controllers['smtp_password']!.text.trim(); + if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd; + payload['email_from_name'] = _controllers['email_from_name']!.text.trim(); + payload['email_from_address'] = _controllers['email_from_address']!.text.trim(); + payload['app_name'] = _controllers['app_name']!.text.trim(); + payload['app_url'] = _controllers['app_url']!.text.trim(); + payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim(); + final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim()); + if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; + final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); + if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; + final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim()); + if (maxMb != null) payload['max_upload_size_mb'] = maxMb; + return payload; + } + + Future _save() async { + setState(() { + _message = null; + _isSaving = true; + }); + try { + await ConfigurationService.updateBulk(_buildPayload()); + if (!mounted) return; + setState(() { + _isSaving = false; + _message = 'Configuration enregistrée.'; + }); + } catch (e) { + if (mounted) { + setState(() { + _isSaving = false; + _message = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + Future _testSmtp() async { + final email = await showDialog( + context: context, + builder: (ctx) { + final c = TextEditingController(); + return AlertDialog( + title: const Text('Tester la connexion SMTP'), + content: TextField( + controller: c, + decoration: const InputDecoration( + labelText: 'Email pour recevoir le test', + hintText: 'admin@example.com', + ), + keyboardType: TextInputType.emailAddress, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () { + final t = c.text.trim(); + if (t.isNotEmpty) Navigator.pop(ctx, t); + }, + child: const Text('Envoyer'), + ), + ], + ); + }, + ); + if (email == null || !mounted) return; + setState(() => _message = null); + try { + await _save(); + if (!mounted) return; + final msg = await ConfigurationService.testSmtp(email); + if (!mounted) return; + setState(() => _message = msg); + } catch (e) { + if (mounted) { + setState(() => _message = e.toString().replaceAll('Exception: ', '')); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_loadError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_loadError!, style: TextStyle(color: Colors.red.shade700)), + const SizedBox(height: 16), + FilledButton( + onPressed: _loadConfiguration, + child: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + final isSuccess = _message != null && + (_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); + + return Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_message != null) ...[ + _MessageBanner(message: _message!, isSuccess: isSuccess), + const SizedBox(height: 20), + ], + _buildSectionCard( + context, + icon: Icons.email_outlined, + title: 'Configuration Email (SMTP)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), + const SizedBox(height: 14), + _buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + children: [ + Checkbox( + value: _smtpSecure, + onChanged: (v) => setState(() => _smtpSecure = v ?? false), + activeColor: const Color(0xFF9CC5C0), + ), + const Text('SSL/TLS (secure)'), + const SizedBox(width: 24), + Checkbox( + value: _smtpAuthRequired, + onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), + activeColor: const Color(0xFF9CC5C0), + ), + const Text('Authentification requise'), + ], + ), + ), + _buildField('smtp_user', 'Utilisateur SMTP'), + const SizedBox(height: 14), + _buildField('smtp_password', 'Mot de passe SMTP', obscure: true), + const SizedBox(height: 14), + _buildField('email_from_name', 'Nom expéditeur'), + const SizedBox(height: 14), + _buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'), + const SizedBox(height: 18), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: _isSaving ? null : _testSmtp, + icon: const Icon(Icons.send_outlined, size: 18), + label: const Text('Tester la connexion SMTP'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF2D6A4F), + side: const BorderSide(color: Color(0xFF9CC5C0)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + _buildSectionCard( + context, + icon: Icons.palette_outlined, + title: 'Personnalisation', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('app_name', 'Nom de l\'application'), + const SizedBox(height: 14), + _buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'), + const SizedBox(height: 14), + _buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'), + ], + ), + ), + const SizedBox(height: 24), + _buildSectionCard( + context, + icon: Icons.settings_outlined, + title: 'Paramètres avancés', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number), + const SizedBox(height: 14), + _buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number), + const SizedBox(height: 14), + _buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _isSaving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF9CC5C0), + foregroundColor: Colors.white, + ), + child: _isSaving + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Sauvegarder la configuration'), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 22, color: const Color(0xFF9CC5C0)), + const SizedBox(width: 10), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ], + ), + const SizedBox(height: 20), + child, + ], + ), + ), + ); + } + + Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) { + final c = _controllers[key]; + if (c == null) return const SizedBox.shrink(); + return TextFormField( + controller: c, + obscureText: obscure, + keyboardType: keyboard, + enabled: !_isSaving, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ); + } +} + +class _MessageBanner extends StatelessWidget { + final String message; + final bool isSuccess; + + const _MessageBanner({required this.message, required this.isSuccess}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSuccess ? Colors.green.shade50 : Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSuccess ? Colors.green.shade200 : Colors.red.shade200, + ), + ), + child: Row( + children: [ + Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + size: 22, + color: isSuccess ? Colors.green.shade700 : Colors.red.shade700, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle( + color: isSuccess ? Colors.green.shade900 : Colors.red.shade900, + fontSize: 14, + ), + ), + ), + ], + ), + ); + } +}