Lot Connexions multi-HEMS : - modèle Installation + InstallationStore (persistance par UUID ; métadonnées SharedPreferences, token via flutter_secure_storage) - ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo, connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP - nymea_service : connect() réveillé (ws://4444, token par UUID), capture Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState - écran Installations (3 états) + déverrouillage installateur depuis la racine - écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser), détection auto via initialSetupRequired, segmenteur de repli - gate de routage (redirect + refreshListenable merge[cm,svc]) ; hasActiveConnection = isSimulation || (connected && authenticated) - en-tête drawer cliquable → Installations - invalidation des caches au switch (service.resetState + clearForSwitch rôles/scheduler/tariff), sans persist() Lot Rôles & appareils : - EnergySetupProvider refondu en tri-état (present/absent/non-configuré) - écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie) - LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ; Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé Correctifs : - bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2 → consumptionW/home en .abs() (autoconso n'est plus clouée à 0) - UUID Hello brace-wrapped normalisé Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts). Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur. Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
575 lines
20 KiB
Dart
575 lines
20 KiB
Dart
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<ConnectionManager>();
|
||
final service = context.watch<NymeaService>();
|
||
final installer = context.watch<InstallerModeProvider>().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<InstallerModeProvider>();
|
||
if (installer) {
|
||
p.lock();
|
||
} else {
|
||
showDialog<void>(
|
||
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<void> _connectTo(BuildContext context, Installation inst) async {
|
||
final manager = context.read<ConnectionManager>();
|
||
final service = context.read<NymeaService>();
|
||
final ok = await manager.switchTo(inst);
|
||
if (!context.mounted) return;
|
||
_afterConnect(context, ok, service);
|
||
}
|
||
|
||
Future<void> _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<ConnectionManager>();
|
||
final service = context.read<NymeaService>();
|
||
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<String>? 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))),
|
||
),
|
||
],
|
||
);
|
||
}
|