import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../providers/installer_mode_provider.dart'; import '../../services/connection_manager.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; enum _AuthBranch { login, create } /// Écran **Connexion** (`/installations/connect`) — auth à deux branches. /// /// La branche est **détectée au handshake** via `initialSetupRequired` (box /// neuve → créer l'admin ; sinon → login). Le segmenteur n'est qu'un **repli /// manuel**. Le token obtenu est persisté **par UUID** (secure storage) par le /// [ConnectionManager]. Créer l'admin = **mode installateur**. class ConnectionScreen extends StatefulWidget { const ConnectionScreen({super.key}); @override State createState() => _ConnectionScreenState(); } class _ConnectionScreenState extends State { late _AuthBranch _branch; bool _busy = false; final _user = TextEditingController(); final _email = TextEditingController(); final _pass = TextEditingController(); final _confirm = TextEditingController(); String? _formError; @override void initState() { super.initState(); // Détection auto : box neuve → créer ; sinon → login. final svc = context.read(); _branch = svc.initialSetupRequired ? _AuthBranch.create : _AuthBranch.login; } @override void dispose() { _user.dispose(); _email.dispose(); _pass.dispose(); _confirm.dispose(); super.dispose(); } Future _submit() async { final manager = context.read(); final user = _user.text.trim(); final pass = _pass.text; if (user.isEmpty || pass.isEmpty) { setState(() => _formError = 'Utilisateur et mot de passe requis.'); return; } if (_branch == _AuthBranch.create && pass != _confirm.text) { setState(() => _formError = 'Les mots de passe ne correspondent pas.'); return; } setState(() { _formError = null; _busy = true; }); final ok = _branch == _AuthBranch.create ? await manager.createAdmin(user, pass, email: _email.text.trim()) : await manager.authenticate(user, pass); if (!mounted) return; if (ok) { context.go('/'); // authentifié → le gate autorise le dashboard } else { setState(() { _busy = false; _formError = manager.error ?? 'Échec de l\'authentification.'; }); } } @override Widget build(BuildContext context) { final svc = context.watch(); final installer = context.watch().isUnlocked; final createLocked = _branch == _AuthBranch.create && !installer; return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Connexion'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ _IdentityHeader( name: svc.serverName.isNotEmpty ? svc.serverName : 'nymea', host: svc.host, port: svc.port, uuid: svc.serverUuid, ), const SizedBox(height: 16), _Segment( branch: _branch, onChanged: (b) => setState(() { _branch = b; _formError = null; }), ), const SizedBox(height: 8), Text( _branch == _AuthBranch.create ? 'Box neuve : aucun administrateur n\'existe encore. Créez le ' 'compte qui pilotera cette installation.' : 'Box déjà initialisée : connectez-vous avec un compte existant.', style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(context), height: 1.35), ), const SizedBox(height: 16), if (createLocked) _LockedCreate() else ...[ _LabeledField( controller: _user, label: 'Utilisateur (e-mail)', hint: 'installateur@etm-schurig.eu', icon: Icons.alternate_email_rounded), if (_branch == _AuthBranch.create) _LabeledField( controller: _email, label: 'E-mail (optionnel)', hint: 'contact@…', icon: Icons.mail_outline_rounded), _LabeledField( controller: _pass, label: 'Mot de passe', hint: '••••••••', icon: Icons.lock_outline_rounded, obscure: true), if (_branch == _AuthBranch.create) _LabeledField( controller: _confirm, label: 'Confirmer le mot de passe', hint: '••••••••', icon: Icons.lock_outline_rounded, obscure: true), if (_formError != null) ...[ const SizedBox(height: 8), Text(_formError!, style: EtmTokens.sans(size: 12.5, color: EtmTokens.danger)), ], const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), ), onPressed: _busy ? null : _submit, child: _busy ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : Text( _branch == _AuthBranch.create ? 'Créer le compte administrateur' : 'Se connecter', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: Colors.white)), ), ), ], const SizedBox(height: 18), _DetectNote(), ], ), ); } } // ═══════════════════════════════════════════════════════════════════════════ class _IdentityHeader extends StatelessWidget { final String name; final String host; final int port; final String uuid; const _IdentityHeader( {required this.name, required this.host, required this.port, required this.uuid}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: EtmTokens.cardShadowOf(context), ), child: Row( children: [ Container( width: 38, height: 38, alignment: Alignment.center, decoration: BoxDecoration( color: EtmTokens.brand.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: const Icon(Icons.power_rounded, color: EtmTokens.brand, size: 20), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: EtmTokens.sans( size: 14.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 2), Text('$host : $port', style: EtmTokens.mono( size: 11.5, color: EtmTokens.mutedOf(context))), if (uuid.isNotEmpty) ...[ const SizedBox(height: 1), Text(uuid, overflow: TextOverflow.ellipsis, style: EtmTokens.mono( size: 10.5, color: EtmTokens.faintOf(context))), ], ], ), ), ], ), ); } } class _Segment extends StatelessWidget { final _AuthBranch branch; final ValueChanged<_AuthBranch> onChanged; const _Segment({required this.branch, required this.onChanged}); @override Widget build(BuildContext context) { Widget seg(String label, _AuthBranch b) { final on = branch == b; return Expanded( child: GestureDetector( onTap: () => onChanged(b), behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), alignment: Alignment.center, decoration: BoxDecoration( color: on ? EtmTokens.brand : Colors.transparent, borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl - 3), ), child: Text(label, style: EtmTokens.sans( size: 13, weight: FontWeight.w600, color: on ? Colors.white : EtmTokens.mutedOf(context))), ), ), ); } return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: EtmTokens.bg2Of(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Row( children: [ seg('Nouvelle', _AuthBranch.create), seg('Existante', _AuthBranch.login), ], ), ); } } class _LabeledField extends StatelessWidget { final TextEditingController controller; final String label; final String hint; final IconData icon; final bool obscure; const _LabeledField({ required this.controller, required this.label, required this.hint, required this.icon, this.obscure = false, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: EtmTokens.sans( size: 12, weight: FontWeight.w600, color: EtmTokens.mutedOf(context))), const SizedBox(height: 6), TextField( controller: controller, obscureText: obscure, style: EtmTokens.sans(size: 14, color: EtmTokens.inkOf(context)), decoration: InputDecoration( prefixIcon: Icon(icon, size: 18, color: EtmTokens.faintOf(context)), hintText: hint, isDense: true, filled: true, fillColor: EtmTokens.bg2Of(context), border: OutlineInputBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), borderSide: BorderSide.none, ), ), ), ], ), ); } } class _LockedCreate extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), border: Border.all(color: EtmTokens.lineOf(context)), ), child: Row( children: [ Icon(Icons.lock_outline_rounded, size: 20, color: EtmTokens.faintOf(context)), const SizedBox(width: 12), Expanded( child: Text( 'Création de l\'administrateur réservée au mode installateur.', style: EtmTokens.sans( size: 13, color: EtmTokens.mutedOf(context), height: 1.35), ), ), ], ), ); } } class _DetectNote extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: EtmTokens.brand.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Text.rich( TextSpan( style: EtmTokens.sans( size: 11.5, color: EtmTokens.mutedOf(context), height: 1.4), children: [ TextSpan( text: 'Détection auto. ', style: EtmTokens.sans( size: 11.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const TextSpan( text: 'La branche est choisie d\'après la box au handshake ; le ' 'segmenteur n\'est qu\'un repli manuel. Le token est stocké ' 'sous l\'UUID de cette installation.'), ], ), ), ); } }