diff --git a/lib/models/nymea_models.dart b/lib/models/nymea_models.dart index 2f0a32f..cd7602f 100644 --- a/lib/models/nymea_models.dart +++ b/lib/models/nymea_models.dart @@ -281,6 +281,26 @@ class FavoriteWidget { this.stateTypeId, this.extra = const {}, }); + + factory FavoriteWidget.fromJson(Map j) => FavoriteWidget( + id: j['id'] as String, + type: FavoriteType.values.firstWhere( + (t) => t.name == j['type'], + orElse: () => FavoriteType.pvPower), + title: j['title'] as String, + thingId: j['thingId'] as String?, + stateTypeId: j['stateTypeId'] as String?, + extra: Map.from(j['extra'] as Map? ?? {}), + ); + + Map toJson() => { + 'id': id, + 'type': type.name, + 'title': title, + if (thingId != null) 'thingId': thingId, + if (stateTypeId != null) 'stateTypeId': stateTypeId, + if (extra.isNotEmpty) 'extra': extra, + }; } // ── Historique d'un état ─────────────────────────────────────────────────────── diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index fa3959d..e613fb7 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -3,11 +3,28 @@ import 'package:provider/provider.dart'; import '../models/energy_data.dart'; import '../models/nymea_models.dart'; import '../services/nymea_service.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; import '../main.dart' show DrawerMenuButton; -class FavoritesScreen extends StatelessWidget { +// ───────────────────────────────────────────────────────────────────────────── +// FavoritesScreen — widgets personnalisables, réordonnables, persistés +// ───────────────────────────────────────────────────────────────────────────── + +class FavoritesScreen extends StatefulWidget { const FavoritesScreen({super.key}); + @override + State createState() => _FavoritesScreenState(); +} + +class _FavoritesScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final s = context.read(); + if (!s.connected) s.startSimulation(); + }); + } @override Widget build(BuildContext context) { @@ -16,65 +33,100 @@ class FavoritesScreen extends StatelessWidget { final favs = service.favoriteWidgets; return Scaffold( - backgroundColor: AppTheme.backgroundGray, - appBar: AppBar( - backgroundColor: AppTheme.backgroundGray, - elevation: 0, - leading: const DrawerMenuButton(), - leadingWidth: 56, - title: const Text('Favoris', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark)), - actions: [ - IconButton( - icon: const Icon(Icons.add_circle_outline, - color: AppTheme.primaryGreen), - onPressed: () => _showAddFavorite(context, service), + 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), + ), + ), + ); + }, + ), + ), ], ), - body: favs.isEmpty - ? _EmptyState(onAdd: () => _showAddFavorite(context, service)) - : ReorderableListView.builder( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - itemCount: favs.length, - onReorder: service.reorderFavorites, - proxyDecorator: (child, index, animation) => Material( - elevation: 6, - borderRadius: BorderRadius.circular(16), - child: child, - ), - itemBuilder: (context, index) { - final fav = favs[index]; - return Padding( - key: ValueKey(fav.id), - padding: const EdgeInsets.only(bottom: 12), - child: _FavoriteCard( - fav: fav, - service: service, - onRemove: () => service.removeFavorite(fav.id), - ), - ); - }, - ), ); }, ); } - void _showAddFavorite(BuildContext context, NymeaService service) { + void _showAddSheet(BuildContext context, NymeaService service) { showModalBottomSheet( context: context, isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20))), - builder: (_) => _AddFavoriteSheet(service: service), + backgroundColor: Colors.transparent, + builder: (_) => _AddSheet(service: service), ); } } -// ── Favorite Card ───────────────────────────────────────────────────────────── +// ─────────────────────────── Carte favori ────────────────────────────────────── class _FavoriteCard extends StatelessWidget { final FavoriteWidget fav; @@ -89,98 +141,85 @@ class _FavoriteCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header with drag handle + title + remove - Row( - children: [ - const Icon(Icons.drag_handle, color: AppTheme.textLight), - const SizedBox(width: 8), - Expanded( - child: Text(fav.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 15)), - ), - IconButton( - icon: const Icon(Icons.close, size: 18, color: AppTheme.textLight), - onPressed: onRemove, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 10), - // Content based on type - _buildContent(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(BuildContext context) { + Widget _buildContent() { final d = service.energyData; - switch (fav.type) { - case FavoriteType.pvPower: - return _BigMetric( - value: _fmt(d.pvPower), - unit: d.pvPower >= 1000 ? 'kW' : 'W', + return switch (fav.type) { + FavoriteType.pvPower => _BigMetric( + value: _fmtVal(d.pvPower), + unit: _fmtUnit(d.pvPower), label: 'Production solaire', icon: Icons.wb_sunny_rounded, - color: AppTheme.solarYellow, - trend: d.pvPower > 100 ? '↑ En production' : 'Pas de production', - ); - - case FavoriteType.homePower: - return _BigMetric( - value: _fmt(d.homePower), - unit: d.homePower >= 1000 ? 'kW' : 'W', + 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: AppTheme.homeBlue, - trend: 'Dont ${d.selfConsumptionRate.toStringAsFixed(0)}% solaire', - ); - - case FavoriteType.batterySOC: - return _BatteryWidget(data: d); - - case FavoriteType.gridPower: - return _BigMetric( - value: _fmt(d.gridPower.abs()), - unit: d.gridPower.abs() >= 1000 ? 'kW' : 'W', - label: d.gridPower >= 0 ? 'Import réseau' : 'Export réseau', + 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 ? Colors.orange : AppTheme.primaryGreen, - trend: d.gridPower >= 0 ? '↓ Achat réseau' : '↑ Vente réseau', - ); - - case FavoriteType.rates: - return _RatesWidget(data: d); - - case FavoriteType.evCharger: - return _EVWidget(data: d, service: service); - - case FavoriteType.thingState: - return _ThingStateWidget(fav: fav, service: service); - - case FavoriteType.chart: - return _MiniChart(historyPoints: service.historyPoints); - } + 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), + }; } - String _fmt(double w) { - if (w >= 1000) return (w / 1000).toStringAsFixed(1); - return w.toStringAsFixed(0); - } + static String _fmtVal(double w) => + w >= 1000 ? (w / 1000).toStringAsFixed(2) : w.toStringAsFixed(0); + + static String _fmtUnit(double w) => w >= 1000 ? 'kW' : 'W'; } -// ── Sub-widgets ─────────────────────────────────────────────────────────────── +// ─────────────────────────── Widgets de contenu ──────────────────────────────── class _BigMetric extends StatelessWidget { final String value; @@ -188,7 +227,7 @@ class _BigMetric extends StatelessWidget { final String label; final IconData icon; final Color color; - final String trend; + final String sub; const _BigMetric({ required this.value, @@ -196,7 +235,7 @@ class _BigMetric extends StatelessWidget { required this.label, required this.icon, required this.color, - required this.trend, + required this.sub, }); @override @@ -204,15 +243,14 @@ class _BigMetric extends StatelessWidget { return Row( children: [ Container( - width: 56, - height: 56, + width: 56, height: 56, decoration: BoxDecoration( - color: color.withValues(alpha:0.12), - borderRadius: BorderRadius.circular(14), + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), ), child: Icon(icon, color: color, size: 30), ), - const SizedBox(width: 16), + const SizedBox(width: 18), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -220,28 +258,20 @@ class _BigMetric extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(value, - style: TextStyle( - fontSize: 34, - fontWeight: FontWeight.bold, - color: color)), - const SizedBox(width: 4), + style: EtmTokens.mono( + size: 36, weight: FontWeight.w700, color: color)), Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.only(bottom: 5, left: 4), child: Text(unit, - style: const TextStyle( - fontSize: 16, color: AppTheme.textLight)), + style: EtmTokens.sans(size: 15, color: EtmTokens.muted)), ), ], ), - Text(label, - style: const TextStyle( - fontSize: 12, color: AppTheme.textLight)), + Text(label, style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), const SizedBox(height: 2), - Text(trend, - style: TextStyle( - fontSize: 11, - color: color, - fontWeight: FontWeight.w500)), + Text(sub, + style: EtmTokens.sans( + size: 11, weight: FontWeight.w500, color: color)), ], ), ], @@ -258,10 +288,10 @@ class _BatteryWidget extends StatelessWidget { final soc = data.batterySOC; final isCharging = data.batteryPower > 0; final color = soc > 50 - ? AppTheme.batteryGreen + ? EtmTokens.green : soc > 20 - ? Colors.orange - : Colors.red; + ? EtmTokens.amber + : EtmTokens.danger; return Row( children: [ @@ -269,28 +299,27 @@ class _BatteryWidget extends StatelessWidget { alignment: Alignment.center, children: [ SizedBox( - width: 80, - height: 80, + width: 80, height: 80, child: CircularProgressIndicator( value: soc / 100, - strokeWidth: 8, - backgroundColor: color.withValues(alpha:0.15), + strokeWidth: 7, + backgroundColor: color.withValues(alpha: 0.12), valueColor: AlwaysStoppedAnimation(color), ), ), Column( + mainAxisSize: MainAxisSize.min, children: [ Text('${soc.toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: color)), + style: EtmTokens.mono( + size: 18, weight: FontWeight.w700, color: color)), Icon( - isCharging - ? Icons.bolt - : Icons.battery_std_rounded, - size: 14, - color: color), + isCharging + ? Icons.bolt_rounded + : Icons.battery_std_rounded, + size: 14, + color: color, + ), ], ), ], @@ -300,19 +329,21 @@ class _BatteryWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('État de charge', - style: const TextStyle( - fontSize: 13, color: AppTheme.textLight)), - const SizedBox(height: 4), + style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), + const SizedBox(height: 6), Text( isCharging - ? '⚡ En charge ${_fmt(data.batteryPower)} W' + ? 'En charge ${_fmt(data.batteryPower)}' : data.batteryPower < -10 - ? '↓ Décharge ${_fmt(data.batteryPower.abs())} W' - : '○ Standby', - style: TextStyle( - fontSize: 13, - color: color, - fontWeight: FontWeight.w600), + ? '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), ), ], ), @@ -320,7 +351,7 @@ class _BatteryWidget extends StatelessWidget { ); } - String _fmt(double w) => w >= 1000 + static String _fmt(double w) => w >= 1000 ? '${(w / 1000).toStringAsFixed(1)} kW' : '${w.toStringAsFixed(0)} W'; } @@ -334,26 +365,30 @@ class _RatesWidget extends StatelessWidget { return Row( children: [ Expanded( - child: _RateItem( - label: 'Autoconsommation', - value: data.selfConsumptionRate, - color: AppTheme.solarYellow)), + child: _RateTile( + label: 'Autoconsommation', + value: data.selfConsumptionRate, + color: EtmTokens.amber, + ), + ), const SizedBox(width: 16), Expanded( - child: _RateItem( - label: 'Autonomie', - value: data.autonomyRate, - color: AppTheme.homeBlue)), + child: _RateTile( + label: 'Autonomie', + value: data.autonomyRate, + color: EtmTokens.blue, + ), + ), ], ); } } -class _RateItem extends StatelessWidget { +class _RateTile extends StatelessWidget { final String label; final double value; final Color color; - const _RateItem( + const _RateTile( {required this.label, required this.value, required this.color}); @override @@ -361,23 +396,20 @@ class _RateItem extends StatelessWidget { return Column( children: [ Text('${value.toStringAsFixed(1)}%', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: color)), - const SizedBox(height: 4), + style: EtmTokens.mono(size: 30, weight: FontWeight.w700, color: color)), + const SizedBox(height: 6), ClipRRect( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(99), child: LinearProgressIndicator( - value: (value / 100).clamp(0, 1), - backgroundColor: color.withValues(alpha:0.15), + value: (value / 100).clamp(0.0, 1.0), + backgroundColor: color.withValues(alpha: 0.12), valueColor: AlwaysStoppedAnimation(color), minHeight: 7, ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text(label, - style: const TextStyle(fontSize: 11, color: AppTheme.textLight), + style: EtmTokens.sans(size: 11, color: EtmTokens.muted), textAlign: TextAlign.center), ], ); @@ -391,60 +423,57 @@ class _EVWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final modeColors = { - ChargingMode.pv: AppTheme.pvGreen, - ChargingMode.minPv: AppTheme.minPvBlue, - ChargingMode.boost: AppTheme.boostRed, + final (color, label) = switch (data.chargingMode) { + ChargingMode.pv => (EtmTokens.amber, 'PV'), + ChargingMode.minPv => (EtmTokens.blue, 'Min+PV'), + ChargingMode.boost => (EtmTokens.green, 'Boost'), }; - final modeLabels = { - ChargingMode.pv: 'PV', - ChargingMode.minPv: 'Min+PV', - ChargingMode.boost: 'Boost', - }; - final color = modeColors[data.chargingMode]!; return Row( children: [ Container( - width: 52, - height: 52, + width: 52, height: 52, decoration: BoxDecoration( - color: color.withValues(alpha:0.12), - borderRadius: BorderRadius.circular(14)), - child: Icon(Icons.electric_car_rounded, color: color, size: 28), + 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.toStringAsFixed(1)} kW', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: color)), - Text('Mode : ${modeLabels[data.chargingMode]}', - style: TextStyle(fontSize: 12, color: color)), + 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 · Réseau ${data.gridLimitOk ? "OK" : "!"}', - style: const TextStyle( - fontSize: 11, color: AppTheme.textLight)), + '${data.solarSourcePercent.toStringAsFixed(0)}% solaire', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), ], ), ), - // Quick mode toggle + // Sélecteur de mode rapide Column( + mainAxisSize: MainAxisSize.min, children: ChargingMode.values.map((m) { - final c = modeColors[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: 8, - height: 8, + width: sel ? 10 : 7, + height: sel ? 10 : 7, margin: const EdgeInsets.symmetric(vertical: 3), decoration: BoxDecoration( - color: data.chargingMode == m ? c : c.withValues(alpha:0.3), + color: sel ? mc : mc.withValues(alpha: 0.3), shape: BoxShape.circle, ), ), @@ -459,17 +488,16 @@ class _EVWidget extends StatelessWidget { class _ThingStateWidget extends StatelessWidget { final FavoriteWidget fav; final NymeaService service; - const _ThingStateWidget( - {required this.fav, required this.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: () => NymeaThing( - id: '', name: 'Introuvable', thingClassId: '', - setupStatus: '', paramValues: [])); - + (t) => t.id == fav.thingId, + orElse: () => const NymeaThing( + id: '', name: 'Introuvable', + thingClassId: '', setupStatus: '', paramValues: []), + ); final value = fav.stateTypeId != null ? thing.stateValue(fav.stateTypeId!) : null; @@ -477,29 +505,26 @@ class _ThingStateWidget extends StatelessWidget { return Row( children: [ Container( - width: 48, - height: 48, + width: 48, height: 48, decoration: BoxDecoration( - color: AppTheme.primaryGreen.withValues(alpha:0.12), - borderRadius: BorderRadius.circular(12)), + color: EtmTokens.green.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), child: const Icon(Icons.device_hub_rounded, - color: AppTheme.primaryGreen, size: 26), + color: EtmTokens.green, size: 26), ), const SizedBox(width: 14), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(thing.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 14)), + style: EtmTokens.sans(size: 14, weight: FontWeight.w600)), const SizedBox(height: 4), - Text(value?.toString() ?? '—', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: AppTheme.primaryGreen)), + Text( + value?.toString() ?? '—', + style: EtmTokens.mono( + size: 24, weight: FontWeight.w700, color: EtmTokens.green), + ), ], ), ], @@ -508,33 +533,39 @@ class _ThingStateWidget extends StatelessWidget { } class _MiniChart extends StatelessWidget { - final List historyPoints; - const _MiniChart({required this.historyPoints}); + final List history; + const _MiniChart({required this.history}); @override Widget build(BuildContext context) { - if (historyPoints.isEmpty) { - return const SizedBox( - height: 60, - child: Center(child: Text('Pas de données', style: TextStyle(color: AppTheme.textLight)))); + 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 = historyPoints.map((p) => p.pvWh).reduce((a, b) => a > b ? a : b); - + final max = history.map((p) => p.pvWh).fold(0.0, (a, b) => a > b ? a : b); return SizedBox( - height: 60, + height: 64, child: Row( crossAxisAlignment: CrossAxisAlignment.end, - children: historyPoints.map((p) { - final h = max > 0 ? (p.pvWh / max) * 50 : 0.0; + children: history.map((p) { + final h = max > 0 ? (p.pvWh / max) : 0.0; return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 1), - child: Container( - height: h.clamp(2, 50), - decoration: BoxDecoration( - color: AppTheme.solarYellow.withValues(alpha:0.7), - borderRadius: const BorderRadius.vertical( - top: Radius.circular(3)), + 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)), + ), ), ), ), @@ -545,7 +576,7 @@ class _MiniChart extends StatelessWidget { } } -// ── Empty state ─────────────────────────────────────────────────────────────── +// ─────────────────────────── État vide ───────────────────────────────────────── class _EmptyState extends StatelessWidget { final VoidCallback onAdd; @@ -558,36 +589,42 @@ class _EmptyState extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - width: 90, - height: 90, + width: 88, height: 88, decoration: BoxDecoration( - color: AppTheme.primaryGreen.withValues(alpha:0.1), + color: EtmTokens.green.withValues(alpha: 0.10), shape: BoxShape.circle, ), child: const Icon(Icons.star_outline_rounded, - color: AppTheme.primaryGreen, size: 48), + color: EtmTokens.green, size: 44), ), - const SizedBox(height: 16), - const Text('Aucun favori', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - color: AppTheme.textDark)), + const SizedBox(height: 18), + Text('Aucun favori', + style: EtmTokens.sans( + size: 18, weight: FontWeight.w600)), const SizedBox(height: 8), - const Text('Ajoutez des widgets pour un accès rapide', - style: TextStyle(color: AppTheme.textLight)), + Text('Ajoutez des widgets pour un accès rapide', + style: EtmTokens.sans(size: 14, color: EtmTokens.muted)), const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add), - label: const Text('Ajouter un widget'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryGreen, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + 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)), + ], + ), ), ), ], @@ -596,114 +633,145 @@ class _EmptyState extends StatelessWidget { } } -// ── Add favorite sheet ──────────────────────────────────────────────────────── +// ─────────────────────────── Feuille d'ajout ─────────────────────────────────── -class _AddFavoriteSheet extends StatelessWidget { +class _AddSheet extends StatelessWidget { final NymeaService service; - const _AddFavoriteSheet({required this.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) { - final options = [ - _FavOption(FavoriteType.pvPower, '☀️ Production PV', - 'Puissance solaire instantanée', Icons.wb_sunny_rounded, - AppTheme.solarYellow), - _FavOption(FavoriteType.homePower, '🏠 Consommation', - 'Puissance consommée', Icons.home_rounded, AppTheme.homeBlue), - _FavOption(FavoriteType.batterySOC, '🔋 Batterie', - 'État de charge', Icons.battery_charging_full_rounded, - AppTheme.batteryGreen), - _FavOption(FavoriteType.gridPower, '⚡ Réseau', - 'Import / Export réseau', Icons.electrical_services_rounded, - AppTheme.gridGray), - _FavOption(FavoriteType.rates, '📊 Taux', - 'Autoconsommation & autonomie', Icons.pie_chart_rounded, - Colors.deepPurple), - _FavOption(FavoriteType.evCharger, '🚗 Borne EV', - 'Recharge véhicule', Icons.electric_car_rounded, - AppTheme.minPvBlue), - _FavOption(FavoriteType.chart, '📈 Graphique', - 'Production journalière', Icons.bar_chart_rounded, - Colors.teal), - ]; - - return DraggableScrollableSheet( - initialChildSize: 0.6, - maxChildSize: 0.9, - minChildSize: 0.4, - expand: false, - builder: (_, controller) => Column( - children: [ - Container( - margin: const EdgeInsets.only(top: 8, bottom: 16), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2)), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Text('Ajouter un widget', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 12), - Expanded( - child: ListView( - controller: controller, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: options.map((opt) { - final alreadyAdded = service.favoriteWidgets - .any((f) => f.type == opt.type); - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: opt.color.withValues(alpha:0.12), - borderRadius: BorderRadius.circular(12)), - child: Icon(opt.icon, color: opt.color), - ), - title: Text(opt.title, - style: const TextStyle( - fontWeight: FontWeight.bold)), - subtitle: Text(opt.subtitle, - style: const TextStyle(fontSize: 12)), - trailing: alreadyAdded - ? const Icon(Icons.check_circle, - color: AppTheme.primaryGreen) - : const Icon(Icons.add_circle_outline, - color: AppTheme.primaryGreen), - onTap: alreadyAdded - ? null - : () { - service.addFavorite(FavoriteWidget( - id: 'fw_${opt.type.name}_${DateTime.now().millisecondsSinceEpoch}', - type: opt.type, - title: opt.title.split(' ').skip(1).join(' '), - )); - Navigator.pop(context); - }, - ), - ); - }).toList(), + 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(), + ), + ), + ], + ), ), ); } } - -class _FavOption { - final FavoriteType type; - final String title; - final String subtitle; - final IconData icon; - final Color color; - const _FavOption( - this.type, this.title, this.subtitle, this.icon, this.color); -} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index b7bc052..f073330 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -75,6 +75,36 @@ class NymeaService extends ChangeNotifier { List _thingClasses = []; List _favoriteWidgets = []; + static const _favKey = 'etm_favorites_v1'; + + NymeaService() { + _loadFavorites(); + } + + Future _loadFavorites() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_favKey); + if (raw != null) { + try { + final list = jsonDecode(raw) as List; + _favoriteWidgets = list + .map((e) => FavoriteWidget.fromJson(e as Map)) + .toList(); + notifyListeners(); + } catch (_) { + // Données corrompues — on repart de zéro + } + } + } + + Future _saveFavorites() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _favKey, + jsonEncode(_favoriteWidgets.map((f) => f.toJson()).toList()), + ); + } + // ── Getters ────────────────────────────────────────────────────────────────── bool get connected => _connected; bool get isConnected => _connected; // alias pour les screens @@ -1185,12 +1215,14 @@ class NymeaService extends ChangeNotifier { if (!_favoriteWidgets.any((f) => f.id == widget.id)) { _favoriteWidgets.add(widget); notifyListeners(); + _saveFavorites(); } } void removeFavorite(String id) { _favoriteWidgets.removeWhere((f) => f.id == id); notifyListeners(); + _saveFavorites(); } void reorderFavorites(int oldIndex, int newIndex) { @@ -1198,6 +1230,7 @@ class NymeaService extends ChangeNotifier { final item = _favoriteWidgets.removeAt(oldIndex); _favoriteWidgets.insert(newIndex, item); notifyListeners(); + _saveFavorites(); } // ═══════════════════════════════════════════════════════════════════════════