etm-powersync-app/lib/screens/favorites_screen.dart
Patrick Schurig ETM-Schurig 80500e21e6 feat: favoris — persistance SharedPreferences + restyle EtmTokens
Persistance (FavoriteWidget)
- FavoriteWidget.fromJson / toJson ajoutés dans nymea_models.dart
- NymeaService : constructeur appelle _loadFavorites() au démarrage
- _saveFavorites() appelé après addFavorite / removeFavorite / reorderFavorites
- Clé SharedPreferences : 'etm_favorites_v1'
- Résistance aux données corrompues (try/catch sur fromJson)

Restyle (favorites_screen.dart)
- AppTheme → EtmTokens sur toutes les couleurs et typographies
- Valeurs en IBM Plex Mono (size 36 pour les grandes métriques)
- Cartes avec EtmTokens.cardShadow + border radius 22
- _EmptyState : bouton vert + étoile + message
- _AddSheet : DraggableScrollableSheet restyled, sans emoji, items visuellement
  distingués (ajouté vs disponible)
- ReorderableDelayedDragStartListener pour le drag discret
- Simulation auto-démarrée si non connecté (comme Dashboard/Things)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:07:14 +02:00

778 lines
27 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.bg,
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bg,
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.card,
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: [
const Icon(Icons.drag_handle_rounded,
color: EtmTokens.faint, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(fav.title,
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: EtmTokens.muted)),
),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.close_rounded,
color: EtmTokens.faint, 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.muted)),
),
],
),
Text(label, style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
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.muted)),
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.muted),
),
],
),
],
);
}
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.muted),
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.muted)),
],
),
),
// 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.muted)),
),
);
}
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.muted)),
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: const BoxDecoration(
color: EtmTokens.card,
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.line,
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: const Icon(Icons.close_rounded,
color: EtmTokens.faint),
),
],
),
),
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.bg : EtmTokens.card,
borderRadius:
BorderRadius.circular(EtmTokens.radius),
border: Border.all(
color: added
? EtmTokens.line
: 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.faint : 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.faint
: EtmTokens.navy)),
Text(subtitle,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.muted)),
],
),
),
Icon(
added
? Icons.check_circle_rounded
: Icons.add_circle_outline_rounded,
color: added ? EtmTokens.green : color,
size: 22,
),
],
),
),
),
);
}).toList(),
),
),
],
),
),
);
}
}