etm-powersync-app/lib/screens/installations/installations_screen.dart
Patrick Schurig bedeabf767 fix(banc): l'adresse du banc passe à 10.42.60.21 — sondes, tests on-device, consignes
Le banc `hems` est renuméroté (192.168.1.75 → 10.42.60.21). Portés : les 11
sondes `tools/rpc/*` et leur README, les 3 tests on-device, le `hint` de
l'écran Installations, les procédures de CLAUDE.md et des deux documents de
cadrage.

Ce qui n'est PAS touché, et c'est délibéré : les relevés datés (RELEVE_LOTC,
RECAP, Prompt 3g2, maquettes) et les fixtures `hems75_*.json`. Un relevé daté
dit ce qui a été observé ce jour-là ; le réécrire échange une trace contre une
cohérence de surface. CLAUDE.md porte la note qui fait le lien : `.75` et
`hems75` désignent la même machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 06:37:33 +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: '10.42.60.21',
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))),
),
],
);
}