etm-powersync-app/lib/screens/favorites_screen.dart
Patrick Schurig 95769938fc fix(ev): la tuile des favoris devient muette — elle lit la borne, ou le dit
Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée
lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire,
elle le dit.

Ce qui est lu, et sa source
  · branché / en charge   ← états pluggedIn, charging du Thing
  · puissance             ← état currentPower du Thing
  · mode                  ← GetChargingInfos + notification ChargingInfoChanged

Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non
reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a
pas dit ce qu'elle porte.

L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui
avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et
affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local :
après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle
avait demandé, ce qui n'est pas ce que la borne porte.

La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et
solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que
_updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais
dans EvChargerLive, champ par champ, nullable.

⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW,
voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en
a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %,
Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle
(GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le
Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider.

Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi)
  · currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu
  · phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard
  · sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on
    attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste
    entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un
    sessionEnergy qui continue.

Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit
pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son
Thing, et le banc n'a pas eu ce cas.

154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus),
flutter analyze à 27 remarques, aucune nouvelle.

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

718 lines
26 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 '../widgets/ev_favorite_tile.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),
// La tuile ne reçoit plus `EnergyData` : ses trois grandeurs de recharge
// n'étaient jamais lues sur la box. Elle lit la borne, ou dit qu'il n'y a
// rien à lire.
FavoriteType.evCharger => EvFavoriteTile(
live: service.evCharger,
onSetMode: (m) => service.setChargingInfo(mode: m)),
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 _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, null),
(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) {
// `null` = teinte neutre : la table est statique, le gris dépend du
// thème, il se résout donc ici.
final (type, title, subtitle, icon, teinte) = opt;
final color = teinte ?? EtmTokens.mutedOf(context);
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(),
),
),
],
),
),
);
}
}