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
538 lines
18 KiB
Dart
538 lines
18 KiB
Dart
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,
|
||
);
|
||
}
|
||
}
|