import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../models/installation.dart'; import '../../providers/installer_mode_provider.dart'; import '../drawer/installer_pin_dialog.dart'; import '../../services/connection_manager.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; /// Écran **Installations** — gestionnaire multi-HEMS. /// /// Trois états (mêmes composants) : vierge (aucune installation) / liste sans /// active / liste avec active. Ajout manuel + suppression = **installateur** ; /// basculer = tout le monde. La découverte mDNS (« Détectées ») viendra à /// l'Étape 3 — la place est réservée mais non remplie. class InstallationsScreen extends StatelessWidget { const InstallationsScreen({super.key}); @override Widget build(BuildContext context) { final manager = context.watch(); final service = context.watch(); final installer = context.watch().isUnlocked; final installs = manager.installations; final blank = installs.isEmpty; return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Installations'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, actions: [ // Accès installateur depuis la racine : sans ça, impossible de // déverrouiller avant toute connexion (le drawer est hors d'atteinte). IconButton( tooltip: installer ? 'Mode installateur actif — toucher pour verrouiller' : 'Déverrouiller le mode installateur', icon: Icon( installer ? Icons.lock_open_rounded : Icons.lock_outline_rounded, color: installer ? EtmTokens.eco : EtmTokens.inkOf(context), ), onPressed: () { final p = context.read(); if (installer) { p.lock(); } else { showDialog( context: context, builder: (_) => const InstallerPinDialog(), ); } }, ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ if (manager.isTransitioning) _Banner( icon: Icons.sync_rounded, color: EtmTokens.brand, text: 'Connexion à ${manager.connectingToName}…', ), if (!manager.isTransitioning && manager.error != null) _Banner( icon: Icons.error_outline_rounded, color: EtmTokens.danger, text: manager.error!, ), // ── Détectées sur le réseau (mDNS) — Étape 3 ────────────────────── _buildDiscoverySection(context), // ── Mes installations ───────────────────────────────────────────── if (!blank) ...[ const _SectionHeader('Mes installations'), const SizedBox(height: 10), for (final inst in installs) ...[ _InstallationCard( inst: inst, active: _isActive(service, manager, inst), showDelete: installer, onTap: () => _connectTo(context, inst), onDelete: () => manager.removeInstallation(inst.uuid), ), const SizedBox(height: 10), ], ] else const _BlankInvite(), const SizedBox(height: 8), // ── Ajout manuel (installateur) ─────────────────────────────────── if (installer) _AddManualButton(onTap: () => _showAddManual(context)) else _LockedHint( 'Ajout d\'installation réservé au mode installateur', ), const SizedBox(height: 16), // ── Mode démo ───────────────────────────────────────────────────── Center( child: GestureDetector( onTap: () { manager.enterDemo(); context.go('/'); }, child: Text.rich( TextSpan( text: 'ou continuer en ', style: EtmTokens.sans( size: 13, color: EtmTokens.mutedOf(context)), children: [ TextSpan( text: 'mode démo', style: EtmTokens.sans( size: 13, weight: FontWeight.w700, color: EtmTokens.brand), ), ], ), ), ), ), ], ), ); } bool _isActive( NymeaService service, ConnectionManager manager, Installation inst) => service.connected && manager.active?.uuid == inst.uuid; /// Place réservée pour la découverte mDNS (Étape 3). Rien à afficher tant que /// le scan n'existe pas (et jamais sur web : pas de multicast navigateur). Widget _buildDiscoverySection(BuildContext context) => const SizedBox.shrink(); // ── Connexion / bascule ──────────────────────────────────────────────────── Future _connectTo(BuildContext context, Installation inst) async { final manager = context.read(); final service = context.read(); final ok = await manager.switchTo(inst); if (!context.mounted) return; _afterConnect(context, ok, service); } Future _showAddManual(BuildContext context) async { final result = await showModalBottomSheet<({String host, int port})>( context: context, isScrollControlled: true, backgroundColor: EtmTokens.surfaceOf(context), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), builder: (_) => const _AddManualSheet(), ); if (result == null || !context.mounted) return; final manager = context.read(); final service = context.read(); final ok = await manager.connectNew(host: result.host, port: result.port); if (!context.mounted) return; _afterConnect(context, ok, service); } /// Suite d'une connexion : authentifié → dashboard ; transport up mais auth /// requise → écran Connexion ; échec → bannière d'erreur (portée par le /// manager). void _afterConnect(BuildContext context, bool ok, NymeaService service) { if (ok && service.authenticated) { context.go('/'); } else if (ok) { // Transport up mais auth requise → écran Connexion (login / créer admin). context.go('/installations/connect'); } // échec : manager.error est affiché par la bannière au prochain build. } } // ═══════════════════════════════════════════════════════════════════════════ // Carte installation // ═══════════════════════════════════════════════════════════════════════════ class _InstallationCard extends StatelessWidget { final Installation inst; final bool active; final bool showDelete; final VoidCallback onTap; final VoidCallback onDelete; const _InstallationCard({ required this.inst, required this.active, required this.showDelete, required this.onTap, required this.onDelete, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: active ? null : onTap, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: EtmTokens.cardShadowOf(context), border: active ? Border.all(color: EtmTokens.eco.withValues(alpha: 0.55), width: 1.5) : null, ), child: Row( children: [ Container( width: 10, height: 10, margin: const EdgeInsets.only(top: 4, right: 12), decoration: BoxDecoration( shape: BoxShape.circle, color: active ? EtmTokens.eco : EtmTokens.faintOf(context), ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( inst.name, overflow: TextOverflow.ellipsis, style: EtmTokens.sans( size: 14.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context)), ), ), const SizedBox(width: 8), if (active) _StateChip('actif', EtmTokens.eco), ], ), const SizedBox(height: 3), Text('nymea://${inst.host} : ${inst.port}', style: EtmTokens.mono( size: 11.5, color: EtmTokens.mutedOf(context))), const SizedBox(height: 2), Text(inst.uuid, overflow: TextOverflow.ellipsis, style: EtmTokens.mono( size: 10.5, color: EtmTokens.faintOf(context))), ], ), ), if (showDelete) IconButton( icon: Icon(Icons.delete_outline_rounded, size: 20, color: EtmTokens.faintOf(context)), onPressed: onDelete, ) else if (!active) Icon(Icons.chevron_right_rounded, color: EtmTokens.faintOf(context)), ], ), ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Ajout manuel (bottom sheet) // ═══════════════════════════════════════════════════════════════════════════ class _AddManualSheet extends StatefulWidget { const _AddManualSheet(); @override State<_AddManualSheet> createState() => _AddManualSheetState(); } class _AddManualSheetState extends State<_AddManualSheet> { final _host = TextEditingController(); final _port = TextEditingController(text: '4444'); @override void dispose() { _host.dispose(); _port.dispose(); super.dispose(); } void _submit() { final host = _host.text.trim(); if (host.isEmpty) return; final port = int.tryParse(_port.text.trim()) ?? 4444; Navigator.of(context).pop((host: host, port: port)); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB( 16, 16, 16, 16 + MediaQuery.viewInsetsOf(context).bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Ajouter manuellement', style: EtmTokens.sans( size: 16, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 4), Text('Adresse IP / hôte de la box (ws clair, port 4444)', style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))), const SizedBox(height: 14), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 3, child: _Field( controller: _host, label: 'Hôte', hint: '192.168.1.75', keyboardType: TextInputType.url, onSubmitted: (_) => _submit(), ), ), const SizedBox(width: 10), Expanded( flex: 1, child: _Field( controller: _port, label: 'Port', hint: '4444', keyboardType: TextInputType.number, onSubmitted: (_) => _submit(), ), ), ], ), 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: _submit, child: Text('Se connecter', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: Colors.white)), ), ), ], ), ); } } class _Field extends StatelessWidget { final TextEditingController controller; final String label; final String hint; final TextInputType keyboardType; final ValueChanged? onSubmitted; const _Field({ required this.controller, required this.label, required this.hint, required this.keyboardType, this.onSubmitted, }); @override Widget build(BuildContext context) { return 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, keyboardType: keyboardType, autofocus: label == 'Hôte', style: EtmTokens.mono(size: 14, color: EtmTokens.inkOf(context)), decoration: InputDecoration( hintText: hint, isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), filled: true, fillColor: EtmTokens.bg2Of(context), border: OutlineInputBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), borderSide: BorderSide.none, ), ), onSubmitted: onSubmitted, ), ], ); } } // ═══════════════════════════════════════════════════════════════════════════ // Petits composants // ═══════════════════════════════════════════════════════════════════════════ class _SectionHeader extends StatelessWidget { final String title; const _SectionHeader(this.title); @override Widget build(BuildContext context) => Text(title, style: EtmTokens.sans( size: 13.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))); } class _StateChip extends StatelessWidget { final String text; final Color color; const _StateChip(this.text, this.color); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(EtmTokens.radiusPill), ), child: Text(text, style: EtmTokens.sans( size: 10.5, weight: FontWeight.w700, color: color)), ); } class _Banner extends StatelessWidget { final IconData icon; final Color color; final String text; const _Banner({required this.icon, required this.color, required this.text}); @override Widget build(BuildContext context) => Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), border: Border.all(color: color.withValues(alpha: 0.25)), ), child: Row( children: [ Icon(icon, size: 18, color: color), const SizedBox(width: 10), Expanded( child: Text(text, style: EtmTokens.sans( size: 12.5, color: EtmTokens.inkOf(context))), ), ], ), ); } class _BlankInvite extends StatelessWidget { const _BlankInvite(); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: EtmTokens.brand.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), border: Border.all(color: EtmTokens.brand.withValues(alpha: 0.18)), ), child: Column( children: [ const Icon(Icons.hub_outlined, color: EtmTokens.brand, size: 32), const SizedBox(height: 12), Text('Aucune installation enregistrée', style: EtmTokens.sans( size: 15, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 6), Text( 'Ajoutez une box par son adresse (ajout manuel) pour vous y ' 'connecter. La découverte réseau arrivera plus tard.', textAlign: TextAlign.center, style: EtmTokens.sans( size: 12.5, color: EtmTokens.mutedOf(context), height: 1.35), ), ], ), ); } class _AddManualButton extends StatelessWidget { final VoidCallback onTap; const _AddManualButton({required this.onTap}); @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 14), alignment: Alignment.center, decoration: BoxDecoration( borderRadius: BorderRadius.circular(EtmTokens.radiusCard), border: Border.all(color: EtmTokens.lineStrongOf(context)), ), child: Text('+ Ajouter manuellement', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), ), ); } class _LockedHint extends StatelessWidget { final String text; const _LockedHint(this.text); @override Widget build(BuildContext context) => Row( children: [ Icon(Icons.lock_outline_rounded, size: 15, color: EtmTokens.faintOf(context)), const SizedBox(width: 8), Expanded( child: Text(text, style: EtmTokens.sans( size: 12, color: EtmTokens.faintOf(context))), ), ], ); }