etm-powersync-app/lib/screens/favorites_screen.dart
Patrick Schurig 51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
Deux manques trouvés en usage réel sur téléphone, contre .75.

── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────

Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing
s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.

Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.

Le chemin est celui qui était demandé, et pour la raison qui était donnée :
  · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
  · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
  · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)

Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.

Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.

── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────

Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.

La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.

⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).

181 tests (+12), flutter analyze inchangé à 27 remarques.

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

719 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.
// Lecture seule depuis le 2026-08-28 : les trois modes se règlent sur l'écran
// « Configurer » de la borne, un seul lieu d'écriture. La note « hors arbitrage »
// reste ici — c'est le client qui ouvre ses favoris.
FavoriteType.evCharger => EvFavoriteTile(live: service.evCharger),
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(),
),
),
],
),
),
);
}
}