etm-powersync-app/lib/screens/things_screen.dart
Patrick Schurig 38475e0007 fix(dashboard+thème): compteurs réseau honnêtes, et le sélecteur clair/sombre s'applique partout
1) « Vers réseau » — TROIS défauts, dont un seul était visible.

   La valeur aberrante (9,10184409403335e+33 kWh) ne vient PAS de l'app : Energy.GetPowerBalance
   la publie telle quelle sur .75. Trois des quatre compteurs cumulés sont corrompus
   (totalReturn 9,1e+33, totalAcquisition 7,5e+33, totalConsumption -1,6e+33) alors que
   totalProduction reste sain, et que les états du Thing compteur sont tous plausibles
   (1429 kWh importés, 88 kWh exportés). C'est donc l'accumulation de l'experience-plugin
   énergie qui déraille, côté box — signalé, pas corrigé ici.

   Les deux autres défauts étaient bien dans l'app :
   - « Depuis réseau » lisait l'OPPOSÉ du compteur d'export au lieu de totalAcquisition,
     qui a son propre compteur. Dès que l'export était positif, la tuile affichait
     0,0 kWh en permanence, quelle que soit l'électricité réellement achetée ;
   - les deux tuiles étaient libellées « aujourd'hui » alors que ces compteurs sont
     CUMULÉS depuis l'origine, jamais remis à zéro.

   Corrigé : EnergyData porte gridExportKwh / gridImportKwh, transportés tels quels — l'app
   ne maquille pas une donnée que la box publie fausse. Un plafond de plausibilité
   (10 GWh, ~3000 ans de consommation) distingue « mesure » de « compteur cassé » : au-delà
   la tuile affiche « — · compteur box invalide ». Imprimer le nombre le présenterait comme
   une mesure ; l'effacer laisserait croire à zéro.

2) Thème : 126 sites basculés des constantes claires vers les variantes contextuelles
   (EtmTokens.bg → bgOf(context), card → surfaceOf, ink → inkOf, muted → mutedOf,
   faint → faintOf, line → lineOf) dans ac_screen, things_screen, energy_screen,
   favorites_screen, main_drawer, ev_charging_card, energy_flow_widget. Ces écrans
   restaient en palette claire quel que soit le sélecteur.

   9 sites RESTENT en constante, et c'est délibéré : ce sont des tables statiques et des
   helpers de haut niveau (categoryInfoMap, _options des favoris, _modeColor, peintres du
   flux) où la couleur est une identité d'icône et où aucun BuildContext n'est en portée.
   Les faire suivre le thème demande de threader le contexte jusqu'à ces tables — un
   refactor à part entière, pas un correctif de fin de soirée.

Tests 80/80, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:13: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.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(info.icon, color: info.color, 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.color.withValues(alpha: 0.20)),
// ── Liste des things ───────────────────────────────────────────
...things.map((t) => _ThingRow(
thing: t,
value: _value(t),
isOnline: _isOnline(t),
color: info.color,
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.color),
title: Text(thing.name, style: EtmTokens.sans(size: 14)),
trailing: value != null
? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.color))
: null,
onTap: onTap,
);
}
}