1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses. Le geste n'est PAS avalé par le ListView parent : les deux montages du test (section seule / section imbriquée) glissent identiquement. La cause du symptôme était bien que .75 n'a qu'une charge par domaine — rien à réordonner. Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un Container transparent. Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui renvoie au bon geste). Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine : [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien écrire. Classement ecs/hvac rétabli ensuite. 2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat. LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance. Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance d'une résistance. La charge produite est alors incomplète, et c'est voulu — le formulaire réclame plutôt que d'inventer une valeur de plaque. La bascule franchit sameHardware() : confirmation préalable qui annonce la reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur d'une PAC réelle, pas celle du banc). Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration héritée » en lecture seule, et conversion seulement sur geste explicite, avec le plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne. 3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte maintenant son libellé, et sa couleur suit le thème. 4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/. 5) ZONES — non entreprises, comme convenu. Tests 93/93, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
781 lines
28 KiB
Dart
781 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, 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(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|