etm-powersync-app/lib/screens/favorites_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

778 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/energy_data.dart';
import '../models/nymea_models.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../main.dart' show DrawerMenuButton;
// ─────────────────────────────────────────────────────────────────────────────
// FavoritesScreen — widgets personnalisables, réordonnables, persistés
// ─────────────────────────────────────────────────────────────────────────────
class FavoritesScreen extends StatefulWidget {
const FavoritesScreen({super.key});
@override
State<FavoritesScreen> createState() => _FavoritesScreenState();
}
class _FavoritesScreenState extends State<FavoritesScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final s = context.read<NymeaService>();
if (!s.connected) s.startSimulation();
});
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final favs = service.favoriteWidgets;
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bgOf(context),
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Text('Favoris',
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => _showAddSheet(context, service),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_rounded,
color: EtmTokens.green, size: 18),
const SizedBox(width: 4),
Text('Ajouter',
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: EtmTokens.green)),
],
),
),
),
),
],
),
if (favs.isEmpty)
SliverFillRemaining(
child: _EmptyState(
onAdd: () => _showAddSheet(context, service)),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverReorderableList(
itemCount: favs.length,
onReorder: service.reorderFavorites,
proxyDecorator: (child, _, animation) => Material(
elevation: 8,
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
shadowColor: EtmTokens.navy.withValues(alpha: 0.15),
child: child,
),
itemBuilder: (context, index) {
final fav = favs[index];
return ReorderableDelayedDragStartListener(
key: ValueKey(fav.id),
index: index,
child: Padding(
padding: const EdgeInsets.only(bottom: 14),
child: _FavoriteCard(
fav: fav,
service: service,
onRemove: () => service.removeFavorite(fav.id),
),
),
);
},
),
),
],
),
);
},
);
}
void _showAddSheet(BuildContext context, NymeaService service) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _AddSheet(service: service),
);
}
}
// ─────────────────────────── Carte favori ──────────────────────────────────────
class _FavoriteCard extends StatelessWidget {
final FavoriteWidget fav;
final NymeaService service;
final VoidCallback onRemove;
const _FavoriteCard({
required this.fav,
required this.service,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
padding: const EdgeInsets.fromLTRB(18, 16, 14, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête : poignée + titre + supprimer
Row(
children: [
Icon(Icons.drag_handle_rounded,
color: EtmTokens.faintOf(context), size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(fav.title,
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: EtmTokens.mutedOf(context))),
),
GestureDetector(
onTap: onRemove,
child: Icon(Icons.close_rounded,
color: EtmTokens.faintOf(context), size: 18),
),
],
),
const SizedBox(height: 14),
_buildContent(),
],
),
);
}
Widget _buildContent() {
final d = service.energyData;
return switch (fav.type) {
FavoriteType.pvPower => _BigMetric(
value: _fmtVal(d.pvPower),
unit: _fmtUnit(d.pvPower),
label: 'Production solaire',
icon: Icons.wb_sunny_rounded,
color: EtmTokens.amber,
sub: d.pvPower > 100 ? 'En production' : 'Pas de production',
),
FavoriteType.homePower => _BigMetric(
value: _fmtVal(d.homePower),
unit: _fmtUnit(d.homePower),
label: 'Consommation maison',
icon: Icons.home_rounded,
color: EtmTokens.blue,
sub: 'Dont ${d.selfConsumptionRate.toStringAsFixed(0)}% solaire',
),
FavoriteType.batterySOC => _BatteryWidget(data: d),
FavoriteType.gridPower => _BigMetric(
value: _fmtVal(d.gridPower.abs()),
unit: _fmtUnit(d.gridPower.abs()),
label: d.gridPower > 0 ? 'Soutirage réseau' : 'Injection réseau',
icon: Icons.electrical_services_rounded,
color: d.gridPower > 0 ? EtmTokens.orange : EtmTokens.green,
sub: d.gridPower > 0 ? 'Achat réseau' : 'Vente réseau',
),
FavoriteType.rates => _RatesWidget(data: d),
FavoriteType.evCharger => _EVWidget(data: d, service: service),
FavoriteType.thingState => _ThingStateWidget(fav: fav, service: service),
FavoriteType.chart => _MiniChart(history: service.historyPoints),
};
}
static String _fmtVal(double w) =>
w >= 1000 ? (w / 1000).toStringAsFixed(2) : w.toStringAsFixed(0);
static String _fmtUnit(double w) => w >= 1000 ? 'kW' : 'W';
}
// ─────────────────────────── Widgets de contenu ────────────────────────────────
class _BigMetric extends StatelessWidget {
final String value;
final String unit;
final String label;
final IconData icon;
final Color color;
final String sub;
const _BigMetric({
required this.value,
required this.unit,
required this.label,
required this.icon,
required this.color,
required this.sub,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: color, size: 30),
),
const SizedBox(width: 18),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(value,
style: EtmTokens.mono(
size: 36, weight: FontWeight.w700, color: color)),
Padding(
padding: const EdgeInsets.only(bottom: 5, left: 4),
child: Text(unit,
style: EtmTokens.sans(size: 15, color: EtmTokens.mutedOf(context))),
),
],
),
Text(label, style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
const SizedBox(height: 2),
Text(sub,
style: EtmTokens.sans(
size: 11, weight: FontWeight.w500, color: color)),
],
),
],
);
}
}
class _BatteryWidget extends StatelessWidget {
final EnergyData data;
const _BatteryWidget({required this.data});
@override
Widget build(BuildContext context) {
final soc = data.batterySOC;
final isCharging = data.batteryPower > 0;
final color = soc > 50
? EtmTokens.green
: soc > 20
? EtmTokens.amber
: EtmTokens.danger;
return Row(
children: [
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 80, height: 80,
child: CircularProgressIndicator(
value: soc / 100,
strokeWidth: 7,
backgroundColor: color.withValues(alpha: 0.12),
valueColor: AlwaysStoppedAnimation(color),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${soc.toStringAsFixed(0)}%',
style: EtmTokens.mono(
size: 18, weight: FontWeight.w700, color: color)),
Icon(
isCharging
? Icons.bolt_rounded
: Icons.battery_std_rounded,
size: 14,
color: color,
),
],
),
],
),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('État de charge',
style: EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))),
const SizedBox(height: 6),
Text(
isCharging
? 'En charge ${_fmt(data.batteryPower)}'
: data.batteryPower < -10
? 'Décharge ${_fmt(data.batteryPower.abs())}'
: 'Standby',
style: EtmTokens.mono(
size: 14, weight: FontWeight.w700, color: color),
),
const SizedBox(height: 4),
Text(
'${data.batteryPower.abs().toStringAsFixed(0)} W',
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context)),
),
],
),
],
);
}
static String _fmt(double w) => w >= 1000
? '${(w / 1000).toStringAsFixed(1)} kW'
: '${w.toStringAsFixed(0)} W';
}
class _RatesWidget extends StatelessWidget {
final EnergyData data;
const _RatesWidget({required this.data});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _RateTile(
label: 'Autoconsommation',
value: data.selfConsumptionRate,
color: EtmTokens.amber,
),
),
const SizedBox(width: 16),
Expanded(
child: _RateTile(
label: 'Autonomie',
value: data.autonomyRate,
color: EtmTokens.blue,
),
),
],
);
}
}
class _RateTile extends StatelessWidget {
final String label;
final double value;
final Color color;
const _RateTile(
{required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('${value.toStringAsFixed(1)}%',
style: EtmTokens.mono(size: 30, weight: FontWeight.w700, color: color)),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: LinearProgressIndicator(
value: (value / 100).clamp(0.0, 1.0),
backgroundColor: color.withValues(alpha: 0.12),
valueColor: AlwaysStoppedAnimation(color),
minHeight: 7,
),
),
const SizedBox(height: 6),
Text(label,
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)),
textAlign: TextAlign.center),
],
);
}
}
class _EVWidget extends StatelessWidget {
final EnergyData data;
final NymeaService service;
const _EVWidget({required this.data, required this.service});
@override
Widget build(BuildContext context) {
final (color, label) = switch (data.chargingMode) {
ChargingMode.pv => (EtmTokens.amber, 'PV'),
ChargingMode.minPv => (EtmTokens.blue, 'Min+PV'),
ChargingMode.boost => (EtmTokens.green, 'Boost'),
};
return Row(
children: [
Container(
width: 52, height: 52,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(Icons.ev_station_rounded, color: color, size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${(data.chargingPower * 1000).toStringAsFixed(0)} W',
style: EtmTokens.mono(
size: 26, weight: FontWeight.w700, color: color)),
Text('Mode $label',
style: EtmTokens.sans(size: 12, color: color)),
Text(
'${data.solarSourcePercent.toStringAsFixed(0)}% solaire',
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
],
),
),
// Sélecteur de mode rapide
Column(
mainAxisSize: MainAxisSize.min,
children: ChargingMode.values.map((m) {
final mc = switch (m) {
ChargingMode.pv => EtmTokens.amber,
ChargingMode.minPv => EtmTokens.blue,
ChargingMode.boost => EtmTokens.green,
};
final sel = data.chargingMode == m;
return GestureDetector(
onTap: () => service.setChargingInfo(mode: m),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: sel ? 10 : 7,
height: sel ? 10 : 7,
margin: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: sel ? mc : mc.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
),
);
}).toList(),
),
],
);
}
}
class _ThingStateWidget extends StatelessWidget {
final FavoriteWidget fav;
final NymeaService service;
const _ThingStateWidget({required this.fav, required this.service});
@override
Widget build(BuildContext context) {
final thing = service.things.firstWhere(
(t) => t.id == fav.thingId,
orElse: () => const NymeaThing(
id: '', name: 'Introuvable',
thingClassId: '', setupStatus: '', paramValues: []),
);
final value = fav.stateTypeId != null
? thing.stateValue(fav.stateTypeId!)
: null;
return Row(
children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: EtmTokens.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.device_hub_rounded,
color: EtmTokens.green, size: 26),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(thing.name,
style: EtmTokens.sans(size: 14, weight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
value?.toString() ?? '—',
style: EtmTokens.mono(
size: 24, weight: FontWeight.w700, color: EtmTokens.green),
),
],
),
],
);
}
}
class _MiniChart extends StatelessWidget {
final List<HistoryPoint> history;
const _MiniChart({required this.history});
@override
Widget build(BuildContext context) {
if (history.isEmpty) {
return SizedBox(
height: 64,
child: Center(
child: Text('Pas de données',
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
),
);
}
final max = history.map((p) => p.pvWh).fold(0.0, (a, b) => a > b ? a : b);
return SizedBox(
height: 64,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: history.map((p) {
final h = max > 0 ? (p.pvWh / max) : 0.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: FractionallySizedBox(
alignment: Alignment.bottomCenter,
heightFactor: h.clamp(0.04, 1.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: EtmTokens.amber.withValues(alpha: 0.8),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(3)),
),
),
),
),
);
}).toList(),
),
);
}
}
// ─────────────────────────── État vide ─────────────────────────────────────────
class _EmptyState extends StatelessWidget {
final VoidCallback onAdd;
const _EmptyState({required this.onAdd});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 88, height: 88,
decoration: BoxDecoration(
color: EtmTokens.green.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: const Icon(Icons.star_outline_rounded,
color: EtmTokens.green, size: 44),
),
const SizedBox(height: 18),
Text('Aucun favori',
style: EtmTokens.sans(
size: 18, weight: FontWeight.w600)),
const SizedBox(height: 8),
Text('Ajoutez des widgets pour un accès rapide',
style: EtmTokens.sans(size: 14, color: EtmTokens.mutedOf(context))),
const SizedBox(height: 24),
GestureDetector(
onTap: onAdd,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: EtmTokens.green,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_rounded, color: Colors.white, size: 18),
const SizedBox(width: 8),
Text('Ajouter un widget',
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: Colors.white)),
],
),
),
),
],
),
);
}
}
// ─────────────────────────── Feuille d'ajout ───────────────────────────────────
class _AddSheet extends StatelessWidget {
final NymeaService service;
const _AddSheet({required this.service});
static const _options = [
(FavoriteType.pvPower, 'Production PV', 'Puissance solaire instantanée', Icons.wb_sunny_rounded, EtmTokens.amber),
(FavoriteType.homePower, 'Consommation', 'Puissance consommée', Icons.home_rounded, EtmTokens.blue),
(FavoriteType.batterySOC,'Batterie', 'État de charge', Icons.battery_charging_full_rounded,EtmTokens.green),
(FavoriteType.gridPower, 'Réseau', 'Soutirage / injection réseau', Icons.electrical_services_rounded, EtmTokens.muted),
(FavoriteType.rates, 'Taux', 'Autoconsommation & autonomie', Icons.pie_chart_rounded, EtmTokens.amber),
(FavoriteType.evCharger, 'Borne EV', 'Recharge véhicule', Icons.ev_station_rounded, EtmTokens.blue),
(FavoriteType.chart, 'Graphique PV', 'Production journalière (barres)', Icons.bar_chart_rounded, EtmTokens.green),
];
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.4,
expand: false,
builder: (_, ctrl) => Column(
children: [
// Poignée
Container(
margin: const EdgeInsets.only(top: 12, bottom: 18),
width: 40, height: 4,
decoration: BoxDecoration(
color: EtmTokens.lineOf(context),
borderRadius: BorderRadius.circular(2)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Ajouter un widget',
style: EtmTokens.sans(
size: 18, weight: FontWeight.w600)),
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(Icons.close_rounded,
color: EtmTokens.faintOf(context)),
),
],
),
),
const SizedBox(height: 4),
Expanded(
child: ListView(
controller: ctrl,
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
children: _options.map((opt) {
final (type, title, subtitle, icon, color) = opt;
final added = service.favoriteWidgets
.any((f) => f.type == type);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: added
? null
: () {
service.addFavorite(FavoriteWidget(
id: 'fw_${type.name}_${DateTime.now().millisecondsSinceEpoch}',
type: type,
title: title,
));
Navigator.pop(context);
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: added ? EtmTokens.bgOf(context) : EtmTokens.surfaceOf(context),
borderRadius:
BorderRadius.circular(EtmTokens.radius),
border: Border.all(
color: added
? EtmTokens.lineOf(context)
: color.withValues(alpha: 0.3),
),
boxShadow: added ? null : EtmTokens.cardShadow,
),
child: Row(
children: [
Container(
width: 42, height: 42,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon,
color: added ? EtmTokens.faintOf(context) : color,
size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: added
? EtmTokens.faintOf(context)
: EtmTokens.navy)),
Text(subtitle,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.mutedOf(context))),
],
),
),
Icon(
added
? Icons.check_circle_rounded
: Icons.add_circle_outline_rounded,
color: added ? EtmTokens.green : color,
size: 22,
),
],
),
),
),
);
}).toList(),
),
),
],
),
),
);
}
}