etm-powersync-app/lib/screens/things_screen.dart
Patrick Schurig aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00

538 lines
18 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:provider/provider.dart';
import '../models/nymea_models.dart';
import '../models/thing_category.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../main.dart' show DrawerMenuButton;
import 'category_overview_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// ThingsScreen — grille de catégories à hauteur intrinsèque
//
// Règle : pas de GridView avec childAspectRatio figé.
// Layout : Column de Row(Expanded × 2) → hauteur naturelle par catégorie.
// ─────────────────────────────────────────────────────────────────────────────
class ThingsScreen extends StatefulWidget {
const ThingsScreen({super.key});
@override
State<ThingsScreen> createState() => _ThingsScreenState();
}
class _ThingsScreenState extends State<ThingsScreen> {
static const _orderedCats = [
ThingCategory.solar, ThingCategory.battery,
ThingCategory.evCharger, ThingCategory.hvac,
ThingCategory.energy, ThingCategory.cars,
ThingCategory.lighting, ThingCategory.sensors,
ThingCategory.network, ThingCategory.weather,
ThingCategory.media, ThingCategory.notifications,
ThingCategory.other,
];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final service = context.read<NymeaService>();
if (!service.connected) service.startSimulation();
});
}
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final things = service.things.where((t) => t.id.isNotEmpty).toList();
final classes = service.thingClasses;
// Groupement par catégorie
final Map<ThingCategory, List<NymeaThing>> grouped = {};
for (final t in things) {
final cls = _classFor(t, classes);
final cat = cls?.category ?? ThingCategory.other;
grouped.putIfAbsent(cat, () => []).add(t);
}
final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList();
// Statut global
final allOnline = things.isNotEmpty &&
things.every((t) => _isOnline(t, _classFor(t, classes)));
final offlineCount = things.where((t) => !_isOnline(t, _classFor(t, classes))).length;
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
body: CustomScrollView(
slivers: [
// AppBar
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bgOf(context),
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Row(
children: [
Text('Things',
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
const SizedBox(width: 8),
if (things.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(8),
),
child: Text('${things.length}',
style: EtmTokens.mono(size: 12, weight: FontWeight.w700,
color: EtmTokens.greenDark)),
),
],
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Icon(Icons.refresh_rounded,
color: EtmTokens.mutedOf(context), size: 20),
onPressed: service.refresh,
),
),
],
),
// Body
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Bandeau statut global
if (service.isConnected || service.isSimulation) ...[
_StatusBanner(
allOnline: allOnline,
offlineCount: offlineCount,
simulation: service.isSimulation,
thingCount: things.length,
),
const SizedBox(height: 16),
],
// État de chargement
if (!service.isConnected && !service.isSimulation)
_Placeholder(
icon: Icons.cloud_off_rounded,
label: 'Non connecté à nymea',
)
else if (things.isEmpty && !service.thingsLoaded)
const Padding(
padding: EdgeInsets.only(top: 60),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2, color: EtmTokens.green),
),
)
else if (things.isEmpty)
_Placeholder(
icon: Icons.devices_other_rounded,
label: 'Aucun appareil configuré',
)
else
_CategoryGrid(
cats: cats,
grouped: grouped,
classes: classes,
),
]),
),
),
],
),
);
}
NymeaThingClass? _classFor(NymeaThing t, List<NymeaThingClass> cls) {
try { return cls.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
}
bool _isOnline(NymeaThing t, NymeaThingClass? cls) {
final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
}
// ─────────────────────────── Bandeau statut ────────────────────────────────────
class _StatusBanner extends StatelessWidget {
final bool allOnline;
final int offlineCount;
final bool simulation;
final int thingCount;
const _StatusBanner({
required this.allOnline,
required this.offlineCount,
required this.simulation,
required this.thingCount,
});
@override
Widget build(BuildContext context) {
final Color dotColor;
final String text;
if (simulation) {
dotColor = EtmTokens.amber;
text = 'Mode simulation · $thingCount appareil(s) simulé(s)';
} else if (allOnline) {
dotColor = EtmTokens.green;
text = 'Tous les appareils connectés · dernière synchro à l\'instant';
} else if (offlineCount > 0) {
dotColor = EtmTokens.orange;
text = '$offlineCount appareil(s) hors ligne';
} else {
dotColor = EtmTokens.faintOf(context);
text = 'Aucun appareil connecté';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
child: Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 10),
Expanded(
child: Text(text,
style: EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))),
),
],
),
);
}
}
// ─────────────────────────── Grille intrinsèque ────────────────────────────────
class _CategoryGrid extends StatelessWidget {
final List<ThingCategory> cats;
final Map<ThingCategory, List<NymeaThing>> grouped;
final List<NymeaThingClass> classes;
const _CategoryGrid({
required this.cats,
required this.grouped,
required this.classes,
});
@override
Widget build(BuildContext context) {
final rows = <Widget>[];
for (int i = 0; i < cats.length; i += 2) {
rows.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start, // hauteur intrinsèque
children: [
Expanded(
child: _CategoryCard(
cat: cats[i],
things: grouped[cats[i]] ?? [],
classes: classes,
onTap: () => _openCategory(context, cats[i], grouped[cats[i]] ?? [], classes),
),
),
const SizedBox(width: 14),
Expanded(
child: i + 1 < cats.length
? _CategoryCard(
cat: cats[i + 1],
things: grouped[cats[i + 1]] ?? [],
classes: classes,
onTap: () => _openCategory(
context, cats[i + 1], grouped[cats[i + 1]] ?? [], classes),
)
: const SizedBox(),
),
],
),
);
if (i + 2 < cats.length) rows.add(const SizedBox(height: 14));
}
return Column(children: rows);
}
void _openCategory(BuildContext context, ThingCategory cat,
List<NymeaThing> things, List<NymeaThingClass> classes) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryOverviewScreen(
info: categoryInfoMap[cat]!,
things: things,
thingClasses: classes,
),
),
);
}
}
// ─────────────────────────── Carte catégorie ───────────────────────────────────
class _CategoryCard extends StatelessWidget {
final ThingCategory cat;
final List<NymeaThing> things;
final List<NymeaThingClass> classes;
final VoidCallback onTap;
const _CategoryCard({
required this.cat,
required this.things,
required this.classes,
required this.onTap,
});
NymeaThingClass? _classFor(NymeaThing t) {
try { return classes.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
}
bool _isOnline(NymeaThing t) {
final cls = _classFor(t);
final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
String? _value(NymeaThing t) {
final cls = _classFor(t);
final p = cls?.primaryStateType;
if (p == null) return null;
return p.formatValue(t.stateValue(p.id));
}
@override
Widget build(BuildContext context) {
final info = categoryInfoMap[cat]!;
final count = things.length;
// Container en dehors de Material pour que l'ombre soit visible
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(EtmTokens.radius),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(EtmTokens.radius),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// ── En-tête ────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
child: Row(
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: info.colorOf(context).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(info.icon, color: info.colorOf(context), size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.label.toUpperCase(),
style: EtmTokens.sans(
size: 9,
weight: FontWeight.w700,
color: EtmTokens.navy.withValues(alpha: 0.6),
letterSpacing: 0.6,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'$count appareil${count > 1 ? 's' : ''}',
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)),
),
],
),
),
],
),
),
// ── Séparateur coloré ──────────────────────────────────────────
Container(height: 1, color: info.colorOf(context).withValues(alpha: 0.20)),
// ── Liste des things ───────────────────────────────────────────
...things.map((t) => _ThingRow(
thing: t,
value: _value(t),
isOnline: _isOnline(t),
color: info.colorOf(context),
isLast: t == things.last,
)),
],
),
),
),
); // closes InkWell
} // closes build
} // closes _CategoryCard
// ─────────────────────────── Ligne thing ───────────────────────────────────────
class _ThingRow extends StatelessWidget {
final NymeaThing thing;
final String? value;
final bool isOnline;
final Color color;
final bool isLast;
const _ThingRow({
required this.thing,
required this.value,
required this.isOnline,
required this.color,
required this.isLast,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 9, 14, 9),
decoration: BoxDecoration(
border: isLast
? null
: Border(bottom: BorderSide(color: EtmTokens.lineOf(context))),
borderRadius: isLast
? const BorderRadius.vertical(bottom: Radius.circular(EtmTokens.radius))
: null,
),
child: Row(
children: [
Container(
width: 7, height: 7,
decoration: BoxDecoration(
color: isOnline ? color : EtmTokens.faintOf(context),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
thing.name,
style: EtmTokens.sans(size: 12, weight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
if (value != null)
Text(
value!,
style: EtmTokens.mono(
size: 12,
weight: FontWeight.w700,
color: isOnline ? color : EtmTokens.mutedOf(context),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
else
Text(
isOnline ? 'OK' : 'Veille',
style: EtmTokens.sans(
size: 12,
color: isOnline ? color : EtmTokens.faintOf(context),
),
),
],
),
);
}
}
// ─────────────────────────── Placeholder ───────────────────────────────────────
class _Placeholder extends StatelessWidget {
final IconData icon;
final String label;
const _Placeholder({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 80),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: EtmTokens.faintOf(context)),
const SizedBox(height: 14),
Text(label, style: EtmTokens.sans(size: 15, color: EtmTokens.mutedOf(context))),
],
),
),
);
}
}
// ─────────────────────────── ThingCard (compat externe) ────────────────────────
class ThingCard extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
final ThingCategoryInfo categoryInfo;
final VoidCallback onTap;
const ThingCard({
super.key,
required this.thing,
required this.thingClass,
required this.categoryInfo,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final primary = thingClass?.primaryStateType;
final value = primary?.formatValue(thing.stateValue(primary.id));
return ListTile(
leading: Icon(categoryInfo.icon, color: categoryInfo.colorOf(context)),
title: Text(thing.name, style: EtmTokens.sans(size: 14)),
trailing: value != null
? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.colorOf(context)))
: null,
onTap: onTap,
);
}
}