etm-powersync-app/lib/screens/installations/installations_screen.dart
Patrick Schurig c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
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>
2026-06-28 08:39:08 +02:00

575 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))),
),
],
);
}