diff --git a/assets/house.svg b/assets/house.svg new file mode 100644 index 0000000..fd6b4ea --- /dev/null +++ b/assets/house.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PV + PAC + BORNE + + + + + + + diff --git a/lib/main.dart b/lib/main.dart index afca94f..cc051bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,7 @@ import 'screens/settings/screens_settings_screen.dart'; import 'screens/things_screen.dart'; import 'services/nymea_service.dart'; import 'theme/app_theme.dart'; -import 'theme/etm_theme.dart'; +import 'theme/etm_tokens.dart'; // ───────────────────────────────────────────────────────────────────────────── // Router GoRouter @@ -321,7 +321,7 @@ class _MainShellState extends State AnimatedBuilder( animation: _slideAnim, builder: (context, _) { - final offset = ETMTheme.drawerWidth * (_slideAnim.value - 1); + final offset = 320.0 * (_slideAnim.value - 1); return Transform.translate( offset: Offset(offset, 0), child: const Align( @@ -383,34 +383,26 @@ class _BottomNav extends StatelessWidget { children: [ AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), decoration: BoxDecoration( color: selected - ? AppTheme.primaryGreen - .withValues(alpha: 0.12) + ? EtmTokens.green.withValues(alpha: 0.12) : Colors.transparent, borderRadius: BorderRadius.circular(12), ), child: Icon( item.icon, - color: selected - ? AppTheme.primaryGreen - : AppTheme.textLight, + color: selected ? EtmTokens.green : EtmTokens.muted, size: 22, ), ), const SizedBox(height: 2), Text( item.label, - style: TextStyle( - fontSize: 10, - color: selected - ? AppTheme.primaryGreen - : AppTheme.textLight, - fontWeight: selected - ? FontWeight.bold - : FontWeight.normal, + style: EtmTokens.sans( + size: 10, + color: selected ? EtmTokens.green : EtmTokens.muted, + weight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ], @@ -442,14 +434,25 @@ class DrawerMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.only(left: 12), child: GestureDetector( onTap: () => context.read().openDrawer(), child: Container( - width: 36, height: 36, + width: 38, height: 38, decoration: BoxDecoration( - color: AppTheme.primaryGreen, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [EtmTokens.green, EtmTokens.greenDark], + ), + boxShadow: [ + BoxShadow( + color: EtmTokens.green.withValues(alpha: 0.35), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), child: const Icon(Icons.bolt, color: Colors.white, size: 22), ), diff --git a/lib/models/nymea_user.dart b/lib/models/nymea_user.dart new file mode 100644 index 0000000..e3e1fdd --- /dev/null +++ b/lib/models/nymea_user.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import '../theme/etm_tokens.dart'; + +/// Les permissions natives de nymea:core (scopes appliqués par le cœur). +enum NymeaPermission { + admin, + controlThings, + configureThings, + executeMagic, + configureMagic, +} + +/// Rôle d'affichage déduit des permissions. +enum EtmRole { utilisateur, installateur, admin } + +extension EtmRoleDisplay on EtmRole { + String get label => switch (this) { + EtmRole.utilisateur => 'UTILISATEUR', + EtmRole.installateur => 'INSTALLATEUR', + EtmRole.admin => 'ADMIN ETM', + }; + + Color get color => switch (this) { + EtmRole.utilisateur => EtmTokens.blue, + EtmRole.installateur => EtmTokens.orange, + EtmRole.admin => EtmTokens.danger, + }; +} + +/// Utilisateur nymea authentifié avec son jeu de permissions. +@immutable +class NymeaUser { + const NymeaUser({ + required this.name, + required this.username, + this.email, + this.permissions = const {}, + }); + + final String name; + final String username; + final String? email; + final Set permissions; + + bool can(NymeaPermission p) => permissions.contains(p); + + bool get canConfigure => + can(NymeaPermission.configureThings) || can(NymeaPermission.configureMagic); + + bool get isAdmin => can(NymeaPermission.admin); + + EtmRole get role { + if (isAdmin) return EtmRole.admin; + if (canConfigure) return EtmRole.installateur; + return EtmRole.utilisateur; + } + + static const Set clientScopes = { + NymeaPermission.controlThings, + NymeaPermission.executeMagic, + }; + static const Set installerScopes = { + NymeaPermission.controlThings, + NymeaPermission.executeMagic, + NymeaPermission.configureThings, + NymeaPermission.configureMagic, + }; + static const Set adminScopes = { + NymeaPermission.admin, + NymeaPermission.controlThings, + NymeaPermission.executeMagic, + NymeaPermission.configureThings, + NymeaPermission.configureMagic, + }; +} diff --git a/lib/models/thing_category.dart b/lib/models/thing_category.dart index 0645672..840b483 100644 --- a/lib/models/thing_category.dart +++ b/lib/models/thing_category.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; import 'nymea_models.dart'; // ───────────────────────────────────────────────────────────────────────────── @@ -105,59 +105,59 @@ const Map categoryInfoMap = { category: ThingCategory.energy, label: 'Compteurs & Prises', icon: Icons.electrical_services_rounded, - color: AppTheme.gridGray, + color: EtmTokens.muted, ), ThingCategory.solar: ThingCategoryInfo( category: ThingCategory.solar, label: 'Solaire', icon: Icons.solar_power_rounded, - color: AppTheme.solarYellow, + color: EtmTokens.amber, ), ThingCategory.battery: ThingCategoryInfo( category: ThingCategory.battery, label: 'Stockage', icon: Icons.battery_charging_full_rounded, - color: AppTheme.batteryGreen, + color: EtmTokens.green, ), ThingCategory.evCharger: ThingCategoryInfo( category: ThingCategory.evCharger, label: 'Chargeurs EV', icon: Icons.electric_car_rounded, - color: AppTheme.minPvBlue, + color: EtmTokens.blue, ), ThingCategory.cars: ThingCategoryInfo( category: ThingCategory.cars, - label: 'Cars', + label: 'Véhicules', icon: Icons.directions_car_rounded, - color: AppTheme.accentTeal, + color: EtmTokens.blue, ), ThingCategory.hvac: ThingCategoryInfo( category: ThingCategory.hvac, - label: 'Chauffage & Climatisation', + label: 'Chauffage & Clim', icon: Icons.thermostat_rounded, - color: Color(0xFFFF7043), + color: EtmTokens.orange, ), ThingCategory.lighting: ThingCategoryInfo( category: ThingCategory.lighting, label: 'Éclairage', icon: Icons.lightbulb_rounded, - color: Color(0xFFFFCA28), + color: EtmTokens.amber, ), ThingCategory.sensors: ThingCategoryInfo( category: ThingCategory.sensors, label: 'Capteurs', icon: Icons.sensors_rounded, - color: Color(0xFF26C6DA), + color: EtmTokens.blue, ), ThingCategory.network: ThingCategoryInfo( category: ThingCategory.network, - label: 'Réseau & Passerelles', + label: 'Réseau', icon: Icons.router_rounded, color: Color(0xFF7C4DFF), ), ThingCategory.notifications: ThingCategoryInfo( category: ThingCategory.notifications, - label: 'Services de notification', + label: 'Notifications', icon: Icons.notifications_rounded, color: Color(0xFFEC407A), ), @@ -165,7 +165,7 @@ const Map categoryInfoMap = { category: ThingCategory.weather, label: 'Météo', icon: Icons.wb_cloudy_rounded, - color: Color(0xFF42A5F5), + color: EtmTokens.blue, ), ThingCategory.media: ThingCategoryInfo( category: ThingCategory.media, @@ -177,7 +177,7 @@ const Map categoryInfoMap = { category: ThingCategory.other, label: 'Autres', icon: Icons.device_hub_rounded, - color: Color(0xFF78909C), + color: EtmTokens.faint, ), }; diff --git a/lib/screens/ac_screen.dart b/lib/screens/ac_screen.dart index 911422c..97b33ee 100644 --- a/lib/screens/ac_screen.dart +++ b/lib/screens/ac_screen.dart @@ -1,7 +1,62 @@ import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; import '../main.dart' show DrawerMenuButton; +// ───────────────────────────────────────────────────────────────────────────── +// ACScreen — Climatisation / Chauffage +// +// Structure (brief) : +// ① Thermostats par pièce EN HAUT (geste fréquent) +// - pièces actives : expandées avec temp + modes +// - pièces éteintes : compactes +// ② Sources pilotées par Héos EN BAS +// - PAC Atlantic (SG-Ready : 4 états) +// - Chauffe-eau thermodynamique (Surplus / Eco / Boost) +// - Climatiseur (rafraîchissement anticipé) +// +// Saison-conscient : hiver = PAC + ECS, été = Clim + ECS. +// La maquette les montre tous pour valider les widgets. +// ───────────────────────────────────────────────────────────────────────────── + +// ── Enums ──────────────────────────────────────────────────────────────────── + +enum ACMode { heat, cool, auto, fan } + +enum SGReadyState { blocked, normal, recommended, forced } + +enum DHWMode { surplus, eco, boost } + +// ── Data models ─────────────────────────────────────────────────────────────── + +class _Zone { + final String name; + final double currentTemp; + final double targetTemp; + final ACMode mode; + final bool isOn; + final bool heosActive; // Héos pilote en ce moment + + const _Zone({ + required this.name, + required this.currentTemp, + required this.targetTemp, + required this.mode, + required this.isOn, + this.heosActive = false, + }); + + _Zone copyWith({double? targetTemp, ACMode? mode, bool? isOn}) => _Zone( + name: name, + currentTemp: currentTemp, + targetTemp: targetTemp ?? this.targetTemp, + mode: mode ?? this.mode, + isOn: isOn ?? this.isOn, + heosActive: heosActive, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── + class ACScreen extends StatefulWidget { const ACScreen({super.key}); @@ -10,327 +65,945 @@ class ACScreen extends StatefulWidget { } class _ACScreenState extends State { - final List<_ACZone> _zones = [ - _ACZone(name: 'Salon', currentTemp: 19.5, targetTemp: 21, mode: ACMode.heat, isOn: true), - _ACZone(name: 'Chambre', currentTemp: 18.0, targetTemp: 19, mode: ACMode.cool, isOn: false), - _ACZone(name: 'Bureau', currentTemp: 20.0, targetTemp: 22, mode: ACMode.heat, isOn: true), - _ACZone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21, mode: ACMode.auto, isOn: false), + final List<_Zone> _zones = [ + const _Zone(name: 'Salon', currentTemp: 19.5, targetTemp: 21.0, mode: ACMode.heat, isOn: true, heosActive: true), + const _Zone(name: 'Chambre', currentTemp: 18.0, targetTemp: 19.0, mode: ACMode.cool, isOn: false), + const _Zone(name: 'Bureau', currentTemp: 20.0, targetTemp: 22.0, mode: ACMode.heat, isOn: true), + const _Zone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21.0, mode: ACMode.auto, isOn: false), ]; + SGReadyState _sgReady = SGReadyState.forced; + bool _sgAuto = true; // Héos pilote SG-Ready + + DHWMode _dhwMode = DHWMode.surplus; + double _dhwCurrent = 52; + double _dhwTarget = 60; + + double _climCurrent = 24.0; + double _climTarget = 22.0; + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppTheme.backgroundGray, - appBar: AppBar( - backgroundColor: AppTheme.backgroundGray, - elevation: 0, - leading: const DrawerMenuButton(), - leadingWidth: 56, - title: const Text( - 'Climatisation / Chauffage', - style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.textDark), - ), - actions: [ - IconButton( - icon: const Icon(Icons.schedule, color: AppTheme.textDark), - onPressed: () {}, + backgroundColor: EtmTokens.bg, + body: CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + backgroundColor: EtmTokens.bg, + elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 64, + title: Text('Climatisation / Chauffage', + style: EtmTokens.sans(size: 18, weight: FontWeight.w600)), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: const Icon(Icons.schedule_rounded, + color: EtmTokens.muted, size: 22), + onPressed: () {}, + tooltip: 'Planning hebdomadaire', + ), + ), + ], + ), + + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), + sliver: SliverList( + delegate: SliverChildListDelegate([ + + // ── ① Thermostats par pièce ───────────────────────────────── + ...List.generate(_zones.length, (i) { + final zone = _zones[i]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: zone.isOn + ? _ActiveZoneCard( + zone: zone, + onChanged: (z) => setState(() => _zones[i] = z), + ) + : _InactiveZoneCard( + zone: zone, + onTurnOn: () => setState( + () => _zones[i] = zone.copyWith(isOn: true)), + ), + ); + }), + + const SizedBox(height: 8), + + // ── ② Sources pilotées par Héos ───────────────────────────── + _SectionLabel('SOURCES PILOTÉES PAR HÉOS'), + const SizedBox(height: 12), + + // PAC Atlantic — SG-Ready + _PACCard( + sgState: _sgReady, + sgAuto: _sgAuto, + onStateChanged: (s) => setState(() { + _sgReady = s; + _sgAuto = false; + }), + onAutoToggle: () => setState(() => _sgAuto = !_sgAuto), + ), + const SizedBox(height: 12), + + // Chauffe-eau thermodynamique + _DHWCard( + mode: _dhwMode, + currentTemp: _dhwCurrent, + targetTemp: _dhwTarget, + onModeChanged: (m) => setState(() => _dhwMode = m), + onTargetChanged: (t) => setState(() => _dhwTarget = t), + ), + const SizedBox(height: 12), + + // Climatiseur — rafraîchissement anticipé + _ClimCard( + currentTemp: _climCurrent, + targetTemp: _climTarget, + onTargetChanged: (t) => setState(() => _climTarget = t), + ), + ]), + ), ), ], ), - body: ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: _zones.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, index) { - return _ZoneCard( - zone: _zones[index], - onChanged: (updated) { - setState(() => _zones[index] = updated); - }, - ); - }, - ), ); } } -enum ACMode { heat, cool, auto, fan } +// ─────────────────────────── Helpers de style ────────────────────────────────── -class _ACZone { - final String name; - final double currentTemp; - final double targetTemp; - final ACMode mode; - final bool isOn; +Color _modeColor(ACMode m) => switch (m) { + ACMode.heat => EtmTokens.orange, + ACMode.cool => EtmTokens.blue, + ACMode.auto => EtmTokens.green, + ACMode.fan => EtmTokens.muted, + }; - const _ACZone({ - required this.name, - required this.currentTemp, - required this.targetTemp, - required this.mode, - required this.isOn, - }); +IconData _modeIcon(ACMode m) => switch (m) { + ACMode.heat => Icons.whatshot_rounded, + ACMode.cool => Icons.ac_unit_rounded, + ACMode.auto => Icons.autorenew_rounded, + ACMode.fan => Icons.air_rounded, + }; - _ACZone copyWith({ - double? targetTemp, - ACMode? mode, - bool? isOn, - }) => - _ACZone( - name: name, - currentTemp: currentTemp, - targetTemp: targetTemp ?? this.targetTemp, - mode: mode ?? this.mode, - isOn: isOn ?? this.isOn, - ); -} +String _modeLabel(ACMode m) => switch (m) { + ACMode.heat => 'Chauffage', + ACMode.cool => 'Clim', + ACMode.auto => 'Auto', + ACMode.fan => 'Ventilation', + }; -class _ZoneCard extends StatelessWidget { - final _ACZone zone; - final ValueChanged<_ACZone> onChanged; +String _modeLabelShort(ACMode m) => switch (m) { + ACMode.heat => 'Chauf.', + ACMode.cool => 'Clim', + ACMode.auto => 'Auto', + ACMode.fan => 'Vent.', + }; - const _ZoneCard({required this.zone, required this.onChanged}); +// ─────────────────────────── Carte zone active ───────────────────────────────── - Color get _modeColor { - switch (zone.mode) { - case ACMode.heat: - return Colors.orange; - case ACMode.cool: - return Colors.cyan; - case ACMode.auto: - return AppTheme.primaryGreen; - case ACMode.fan: - return Colors.blueGrey; - } - } +class _ActiveZoneCard extends StatelessWidget { + final _Zone zone; + final ValueChanged<_Zone> onChanged; - IconData get _modeIcon { - switch (zone.mode) { - case ACMode.heat: - return Icons.whatshot_rounded; - case ACMode.cool: - return Icons.ac_unit_rounded; - case ACMode.auto: - return Icons.autorenew_rounded; - case ACMode.fan: - return Icons.air_rounded; - } - } - - String get _modeLabel { - switch (zone.mode) { - case ACMode.heat: - return 'Chauffage'; - case ACMode.cool: - return 'Clim'; - case ACMode.auto: - return 'Auto'; - case ACMode.fan: - return 'Ventilation'; - } - } + const _ActiveZoneCard({required this.zone, required this.onChanged}); @override Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // Header row - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: zone.isOn - ? _modeColor.withValues(alpha:0.15) - : Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _modeIcon, - color: zone.isOn ? _modeColor : Colors.grey, - size: 26, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(zone.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 16)), - Text( - zone.isOn ? _modeLabel : 'Éteint', - style: TextStyle( - fontSize: 12, - color: - zone.isOn ? _modeColor : AppTheme.textLight), - ), - ], - ), - ), - Switch( - value: zone.isOn, - onChanged: (v) => onChanged(zone.copyWith(isOn: v)), - activeThumbColor: _modeColor, - ), - ], - ), + final color = _modeColor(zone.mode); - if (zone.isOn) ...[ - const Divider(height: 20), + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(_modeIcon(zone.mode), color: color, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(zone.name, + style: EtmTokens.sans(size: 16, weight: FontWeight.w600)), + Text(_modeLabel(zone.mode), + style: EtmTokens.sans(size: 12, color: color)), + ], + ), + ), + Switch( + value: zone.isOn, + onChanged: (v) => onChanged(zone.copyWith(isOn: v)), + activeColor: color, + ), + ], + ), - // Temperature - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Divider(height: 1, color: EtmTokens.line), + ), + + // Températures + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( + Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Text('${zone.currentTemp.toStringAsFixed(1)}°C', + style: EtmTokens.mono(size: 28, weight: FontWeight.w700)), + ], + ), + Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Row( children: [ - const Text('Actuelle', - style: TextStyle( - fontSize: 12, color: AppTheme.textLight)), - Text( - '${zone.currentTemp.toStringAsFixed(1)}°C', - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - ), + _TempButton( + icon: Icons.remove, + color: color, + onTap: () => onChanged( + zone.copyWith(targetTemp: zone.targetTemp - 0.5)), ), - ], - ), - Icon(Icons.arrow_forward_rounded, - color: Colors.grey.shade400), - Column( - children: [ - const Text('Cible', - style: TextStyle( - fontSize: 12, color: AppTheme.textLight)), - Row( - children: [ - IconButton( - icon: Icon(Icons.remove_circle_outline, - color: _modeColor), - onPressed: () => onChanged(zone.copyWith( - targetTemp: zone.targetTemp - 0.5)), - ), - Text( - '${zone.targetTemp.toStringAsFixed(1)}°C', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: _modeColor, - ), - ), - IconButton( - icon: Icon(Icons.add_circle_outline, - color: _modeColor), - onPressed: () => onChanged(zone.copyWith( - targetTemp: zone.targetTemp + 0.5)), - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text('${zone.targetTemp.toStringAsFixed(1)}°C', + style: EtmTokens.mono(size: 24, weight: FontWeight.w700, + color: color)), + ), + _TempButton( + icon: Icons.add, + color: color, + onTap: () => onChanged( + zone.copyWith(targetTemp: zone.targetTemp + 0.5)), ), ], ), ], ), + ], + ), - // Mode selector - const SizedBox(height: 8), - Row( - children: ACMode.values.map((m) { - final selected = zone.mode == m; - final color = _modeColorFor(m); - final icon = _modeIconFor(m); - final label = _modeLabelFor(m); - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: GestureDetector( - onTap: () => onChanged(zone.copyWith(mode: m)), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: selected - ? color.withValues(alpha:0.15) - : Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), - border: selected - ? Border.all(color: color, width: 1.5) - : null, - ), - child: Column( - children: [ - Icon(icon, - size: 18, - color: selected ? color : Colors.grey), - const SizedBox(height: 2), - Text(label, - style: TextStyle( - fontSize: 10, - color: - selected ? color : AppTheme.textLight, - fontWeight: selected - ? FontWeight.bold - : FontWeight.normal)), - ], - ), + const SizedBox(height: 14), + + // Sélecteur mode + Row( + children: ACMode.values.map((m) { + final sel = zone.mode == m; + final mcol = _modeColor(m); + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: GestureDetector( + onTap: () => onChanged(zone.copyWith(mode: m)), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: sel ? mcol.withValues(alpha: 0.12) : EtmTokens.bg, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: sel ? mcol : EtmTokens.line, + width: sel ? 1.5 : 1, ), ), + child: Column( + children: [ + Icon(_modeIcon(m), + size: 18, + color: sel ? mcol : EtmTokens.faint), + const SizedBox(height: 3), + Text(_modeLabelShort(m), + style: EtmTokens.sans( + size: 10, + color: sel ? mcol : EtmTokens.muted, + weight: sel ? FontWeight.w600 : FontWeight.w400)), + ], + ), ), - ); - }).toList(), - ), - ], + ), + ), + ); + }).toList(), + ), + + // Chip Héos si actif + if (zone.heosActive) ...[ + const SizedBox(height: 12), + _HeosChip('Chauffe au solaire en ce moment'), ], - ), + ], ), ); } +} - Color _modeColorFor(ACMode m) { - switch (m) { - case ACMode.heat: - return Colors.orange; - case ACMode.cool: - return Colors.cyan; - case ACMode.auto: - return AppTheme.primaryGreen; - case ACMode.fan: - return Colors.blueGrey; - } - } +// ─────────────────────────── Carte zone inactive ─────────────────────────────── - IconData _modeIconFor(ACMode m) { - switch (m) { - case ACMode.heat: - return Icons.whatshot_rounded; - case ACMode.cool: - return Icons.ac_unit_rounded; - case ACMode.auto: - return Icons.autorenew_rounded; - case ACMode.fan: - return Icons.air_rounded; - } - } +class _InactiveZoneCard extends StatelessWidget { + final _Zone zone; + final VoidCallback onTurnOn; - String _modeLabelFor(ACMode m) { - switch (m) { - case ACMode.heat: - return 'Chauf.'; - case ACMode.cool: - return 'Clim'; - case ACMode.auto: - return 'Auto'; - case ACMode.fan: - return 'Vent.'; - } + const _InactiveZoneCard({required this.zone, required this.onTurnOn}); + + @override + Widget build(BuildContext context) { + return _Card( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row( + children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: EtmTokens.bg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(_modeIcon(zone.mode), color: EtmTokens.faint, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(zone.name, + style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), + Text('Éteint', style: EtmTokens.sans(size: 12, color: EtmTokens.faint)), + ], + ), + ), + Switch(value: false, onChanged: (_) => onTurnOn(), activeColor: EtmTokens.green), + ], + ), + ); } -} \ No newline at end of file +} + +// ─────────────────────────── PAC SG-Ready ────────────────────────────────────── + +class _PACCard extends StatelessWidget { + final SGReadyState sgState; + final bool sgAuto; + final ValueChanged onStateChanged; + final VoidCallback onAutoToggle; + + const _PACCard({ + required this.sgState, + required this.sgAuto, + required this.onStateChanged, + required this.onAutoToggle, + }); + + @override + Widget build(BuildContext context) { + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: EtmTokens.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.heat_pump_outlined, + color: EtmTokens.orange, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('PAC Atlantic', + style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), + Text('Source de chauffage · SG-Ready', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + ], + ), + ), + _HeosBadgeSmall(), + ], + ), + + const SizedBox(height: 14), + + // Label + toggle Auto + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('MODE SG-READY', + style: EtmTokens.sectionLabel()), + GestureDetector( + onTap: onAutoToggle, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: sgAuto ? EtmTokens.greenSoft : EtmTokens.bg, + borderRadius: BorderRadius.circular(99), + border: Border.all( + color: sgAuto ? EtmTokens.green : EtmTokens.line, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.auto_awesome_rounded, + size: 12, + color: sgAuto ? EtmTokens.green : EtmTokens.muted), + const SizedBox(width: 4), + Text('Auto', + style: EtmTokens.sans( + size: 11, + weight: FontWeight.w600, + color: sgAuto ? EtmTokens.green : EtmTokens.muted)), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 10), + + // 4 états SG-Ready + Row( + children: SGReadyState.values.map((s) { + final isBlocked = s == SGReadyState.blocked; + final sel = !sgAuto && sgState == s; + final (label, color) = switch (s) { + SGReadyState.blocked => ('Bloqué', EtmTokens.danger), + SGReadyState.normal => ('Normal', EtmTokens.muted), + SGReadyState.recommended => ('Recommandé', EtmTokens.amber), + SGReadyState.forced => ('Forcé', EtmTokens.green), + }; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: GestureDetector( + onTap: isBlocked ? null : () => onStateChanged(s), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: isBlocked + ? EtmTokens.bg + : sel + ? color.withValues(alpha: 0.14) + : EtmTokens.bg, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: sel ? color : EtmTokens.line, + width: sel ? 1.5 : 1, + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: EtmTokens.sans( + size: 11, + weight: sel ? FontWeight.w600 : FontWeight.w400, + color: isBlocked + ? EtmTokens.faint + : sel ? color : EtmTokens.muted, + ), + ), + ), + ), + ), + ); + }).toList(), + ), + + const SizedBox(height: 14), + + // Info surplus + _SurplusInfo( + label: 'Surplus solaire 2,4 kW', + detail: 'chauffe au solaire en stockant l\'énergie gratuite.', + powerKw: 1.8, + solarPct: 100, + ), + ], + ), + ); + } +} + +// ─────────────────────────── Chauffe-eau ─────────────────────────────────────── + +class _DHWCard extends StatelessWidget { + final DHWMode mode; + final double currentTemp; + final double targetTemp; + final ValueChanged onModeChanged; + final ValueChanged onTargetChanged; + + const _DHWCard({ + required this.mode, + required this.currentTemp, + required this.targetTemp, + required this.onModeChanged, + required this.onTargetChanged, + }); + + @override + Widget build(BuildContext context) { + final (modeColor, modeIcon, modeLabel) = switch (mode) { + DHWMode.surplus => (EtmTokens.green, Icons.wb_sunny_rounded, 'Surplus'), + DHWMode.eco => (EtmTokens.blue, Icons.eco_rounded, 'Éco'), + DHWMode.boost => (EtmTokens.amber, Icons.bolt_rounded, 'Boost'), + }; + + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: EtmTokens.blue.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.water_drop_outlined, + color: EtmTokens.blue, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Chauffe-eau', + style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), + Text('Ballon thermodynamique', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + ], + ), + ), + _HeosBadgeSmall(), + ], + ), + + const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Divider(height: 1, color: EtmTokens.line), + ), + + // Températures + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Température eau', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Text('${currentTemp.toStringAsFixed(0)}°C', + style: EtmTokens.mono(size: 28, weight: FontWeight.w700)), + ], + ), + Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Row( + children: [ + _TempButton( + icon: Icons.remove, + color: modeColor, + onTap: () => onTargetChanged(targetTemp - 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text('${targetTemp.toStringAsFixed(0)}°C', + style: EtmTokens.mono(size: 24, weight: FontWeight.w700, + color: modeColor)), + ), + _TempButton( + icon: Icons.add, + color: modeColor, + onTap: () => onTargetChanged(targetTemp + 1)), + ], + ), + ], + ), + ], + ), + + const SizedBox(height: 14), + + // 3 modes DHW + Row( + children: DHWMode.values.map((m) { + final sel = mode == m; + final (mc, mi, ml) = switch (m) { + DHWMode.surplus => (EtmTokens.green, Icons.wb_sunny_rounded, 'Surplus'), + DHWMode.eco => (EtmTokens.blue, Icons.eco_rounded, 'Éco'), + DHWMode.boost => (EtmTokens.amber, Icons.bolt_rounded, 'Boost'), + }; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: GestureDetector( + onTap: () => onModeChanged(m), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: sel ? mc.withValues(alpha: 0.12) : EtmTokens.bg, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: sel ? mc : EtmTokens.line, + width: sel ? 1.5 : 1, + ), + ), + child: Column( + children: [ + Icon(mi, size: 20, color: sel ? mc : EtmTokens.faint), + const SizedBox(height: 4), + Text(ml, + style: EtmTokens.sans( + size: 12, + weight: sel ? FontWeight.w600 : FontWeight.w400, + color: sel ? mc : EtmTokens.muted)), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + + const SizedBox(height: 14), + + // Info + _SurplusInfo( + label: 'Surplus solaire', + detail: 'chauffe l\'eau avant le soir', + powerKw: 1.2, + solarPct: 100, + ), + ], + ), + ); + } +} + +// ─────────────────────────── Climatiseur anticipé ────────────────────────────── + +class _ClimCard extends StatelessWidget { + final double currentTemp; + final double targetTemp; + final ValueChanged onTargetChanged; + + const _ClimCard({ + required this.currentTemp, + required this.targetTemp, + required this.onTargetChanged, + }); + + @override + Widget build(BuildContext context) { + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: EtmTokens.blue.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.ac_unit_rounded, + color: EtmTokens.blue, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Climatiseur', + style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), + Text('Rafraîchissement anticipé', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + ], + ), + ), + ], + ), + + const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Divider(height: 1, color: EtmTokens.line), + ), + + // Températures + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Text('${currentTemp.toStringAsFixed(1)}°C', + style: EtmTokens.mono(size: 28, weight: FontWeight.w700)), + ], + ), + const Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Row( + children: [ + _TempButton( + icon: Icons.remove, + color: EtmTokens.blue, + onTap: () => onTargetChanged(targetTemp - 0.5)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text('${targetTemp.toStringAsFixed(1)}°C', + style: EtmTokens.mono(size: 24, weight: FontWeight.w700, + color: EtmTokens.blue)), + ), + _TempButton( + icon: Icons.add, + color: EtmTokens.blue, + onTap: () => onTargetChanged(targetTemp + 0.5)), + ], + ), + ], + ), + ], + ), + + const SizedBox(height: 14), + + // Info Héos + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: EtmTokens.blueSoft, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.schedule_rounded, + size: 14, color: EtmTokens.blue), + const SizedBox(width: 6), + Text('Pré-refroidissement 13h–16h', + style: EtmTokens.sans(size: 12, weight: FontWeight.w600, + color: EtmTokens.blue)), + ], + ), + const SizedBox(height: 4), + Text( + 'Sur surplus solaire, pour traverser le pic tarifaire ' + '17h–21h sans clim payante.', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted), + ), + const SizedBox(height: 8), + Row( + children: [ + Text('820 W', style: EtmTokens.mono(size: 12, color: EtmTokens.blue)), + Text(' · 100% solaire', + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +// ─────────────────────────── Widgets partagés ────────────────────────────────── + +class _Card extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + const _Card({required this.child, this.padding}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, + ), + padding: padding ?? const EdgeInsets.all(20), + child: child, + ); + } +} + +class _TempButton extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _TempButton({required this.icon, required this.color, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 30, height: 30, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: color), + ), + ); + } +} + +class _HeosChip extends StatelessWidget { + final String label; + const _HeosChip(this.label); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: EtmTokens.greenSoft, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wb_sunny_rounded, size: 12, color: EtmTokens.greenDark), + const SizedBox(width: 5), + Text(label, + style: EtmTokens.sans(size: 11, weight: FontWeight.w500, + color: EtmTokens.greenDark)), + ], + ), + ); + } +} + +class _HeosBadgeSmall extends StatelessWidget { + const _HeosBadgeSmall(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: EtmTokens.greenSoft, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.auto_awesome_rounded, size: 10, color: EtmTokens.greenDark), + const SizedBox(width: 4), + Text('Piloté par Héos', + style: EtmTokens.sans(size: 10, weight: FontWeight.w600, + color: EtmTokens.greenDark)), + ], + ), + ); + } +} + +class _SurplusInfo extends StatelessWidget { + final String label; + final String detail; + final double powerKw; + final int solarPct; + + const _SurplusInfo({ + required this.label, + required this.detail, + required this.powerKw, + required this.solarPct, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: EtmTokens.greenSoft, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const Icon(Icons.wb_sunny_rounded, size: 14, color: EtmTokens.greenDark), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: EtmTokens.sans(size: 11, color: EtmTokens.muted), + children: [ + TextSpan( + text: '$label ', + style: EtmTokens.sans(size: 11, weight: FontWeight.w600, + color: EtmTokens.greenDark), + ), + TextSpan(text: detail), + ], + ), + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('${powerKw.toStringAsFixed(1)} kW', + style: EtmTokens.mono(size: 11, color: EtmTokens.green)), + Text('$solarPct% solaire', + style: EtmTokens.sans(size: 10, color: EtmTokens.muted)), + ], + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + return Text(text, style: EtmTokens.sectionLabel()); + } +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index c14e1d2..04ec9ec 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../main.dart' show DrawerMenuButton; +import '../models/energy_data.dart'; import '../services/nymea_service.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; import '../widgets/energy_flow_widget.dart'; -import '../widgets/production_card.dart'; import '../widgets/ev_charging_card.dart'; -import '../widgets/gains_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -19,12 +18,9 @@ class _DashboardScreenState extends State { @override void initState() { super.initState(); - // Start simulation on first load WidgetsBinding.instance.addPostFrameCallback((_) { final service = context.read(); - if (!service.connected) { - service.startSimulation(); - } + if (!service.connected) service.startSimulation(); }); } @@ -33,81 +29,32 @@ class _DashboardScreenState extends State { return Consumer( builder: (context, service, _) { final data = service.energyData; - return Scaffold( - backgroundColor: AppTheme.backgroundGray, + backgroundColor: EtmTokens.bg, body: RefreshIndicator( - onRefresh: () async { - service.startSimulation(); - }, - color: AppTheme.primaryGreen, + onRefresh: () async => service.startSimulation(), + color: EtmTokens.green, child: CustomScrollView( slivers: [ - // App bar - SliverAppBar( - floating: true, - backgroundColor: AppTheme.backgroundGray, - elevation: 0, - leading: const DrawerMenuButton(), - leadingWidth: 56, - title: const Text( - 'ETM PowerSync', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 18, - ), - ), - actions: [ - // Connection status - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - icon: Icon( - service.connected - ? Icons.wifi_rounded - : Icons.wifi_off_rounded, - color: service.connected - ? AppTheme.primaryGreen - : Colors.red, - ), - onPressed: () => _showConnectionDialog(context, service), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 12), - child: IconButton( - icon: const Icon(Icons.notifications_outlined, - color: AppTheme.textDark), - onPressed: () {}, - ), - ), - ], - ), - + _DashAppBar(service: service), SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), sliver: SliverList( delegate: SliverChildListDelegate([ - // Energy flow + const SizedBox(height: 8), + _SystemHeroCard(data: data), + const SizedBox(height: 16), EnergyFlowWidget(data: data), - const SizedBox(height: 12), - - // Self-consumption rates - _RatesRow(data: data), - const SizedBox(height: 12), - - // Production card - ProductionCard(data: data), - const SizedBox(height: 12), - - // Gains - GainsCard(data: data), - const SizedBox(height: 12), - - // EV Charging + const SizedBox(height: 16), EVChargingCard(data: data, service: service), const SizedBox(height: 16), + _KpiGrid(data: data), + const SizedBox(height: 16), + _ConsumersCard(data: data), + const SizedBox(height: 16), + _HeosDecisionsCard(), + const SizedBox(height: 16), + _ForecastCard(), ]), ), ), @@ -118,428 +65,876 @@ class _DashboardScreenState extends State { }, ); } +} + +// ─────────────────────────────── AppBar ──────────────────────────────────────── + +class _DashAppBar extends StatelessWidget { + final NymeaService service; + const _DashAppBar({required this.service}); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + floating: true, + backgroundColor: EtmTokens.bg, + elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 64, + title: Text('ETM PowerSync', + style: EtmTokens.sans(size: 20, weight: FontWeight.w600)), + actions: [ + IconButton( + icon: Icon( + service.connected ? Icons.wifi_rounded : Icons.wifi_off_rounded, + color: service.connected ? EtmTokens.green : EtmTokens.danger, + ), + onPressed: () => _showConnectionDialog(context, service), + ), + Padding( + padding: const EdgeInsets.only(right: 16), + child: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.notifications_outlined, color: EtmTokens.navy, size: 24), + Positioned( + right: -1, top: -1, + child: Container( + width: 8, height: 8, + decoration: BoxDecoration( + color: EtmTokens.green, + shape: BoxShape.circle, + border: Border.all(color: EtmTokens.bg, width: 1.5), + ), + ), + ), + ], + ), + ), + ], + ); + } void _showConnectionDialog(BuildContext context, NymeaService service) { showDialog( context: context, - builder: (ctx) => _ConnectionDialog(service: service), - ); - } -} - -class _RatesRow extends StatelessWidget { - final dynamic data; - - const _RatesRow({required this.data}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: _RateCard( - label: 'Autoconsommation', - value: data.selfConsumptionRate, - icon: Icons.wb_sunny_rounded, - color: AppTheme.solarYellow, - ), + builder: (_) => AlertDialog( + title: Text('Connexion nymea', + style: EtmTokens.sans(size: 18, weight: FontWeight.w600)), + content: Text( + service.connected + ? 'Connecté à ${service.host}' + : service.isSimulation + ? 'Mode simulation actif' + : 'Non connecté', + style: EtmTokens.sans(size: 14, color: EtmTokens.muted), ), - const SizedBox(width: 12), - Expanded( - child: _RateCard( - label: 'Autonomie', - value: data.autonomyRate, - icon: Icons.home_rounded, - color: AppTheme.homeBlue, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('OK', style: EtmTokens.sans(color: EtmTokens.green)), ), - ), - ], - ); - } -} - -class _RateCard extends StatelessWidget { - final String label; - final double value; - final IconData icon; - final Color color; - - const _RateCard({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 6), - Expanded( - child: Text( - label, - style: const TextStyle( - fontSize: 12, color: AppTheme.textLight), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - '${value.toStringAsFixed(1)}%', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: (value / 100).clamp(0, 1), - backgroundColor: color.withValues(alpha:0.15), - valueColor: AlwaysStoppedAnimation(color), - minHeight: 6, - ), - ), - ], - ), - ), - ); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Dialog de connexion avec sélection du protocole TCP / WebSocket -// ═══════════════════════════════════════════════════════════════════════════ - -class _ConnectionDialog extends StatefulWidget { - final NymeaService service; - const _ConnectionDialog({required this.service}); - - @override - State<_ConnectionDialog> createState() => _ConnectionDialogState(); -} - -class _ConnectionDialogState extends State<_ConnectionDialog> { - late TextEditingController _hostCtrl; - late TextEditingController _portCtrl; - late TextEditingController _userCtrl; - late TextEditingController _passCtrl; - late NymeaProtocol _protocol; - bool _connecting = false; - bool _verbose = false; - - // Ports par défaut selon protocole - static const _defaultPorts = { - NymeaProtocol.tcpRaw: 2222, - NymeaProtocol.webSocket: 4444, - }; - - @override - void initState() { - super.initState(); - _protocol = widget.service.protocol; - _hostCtrl = TextEditingController(text: widget.service.host); - _portCtrl = TextEditingController(text: widget.service.port.toString()); - _userCtrl = TextEditingController(text: widget.service.username); - _passCtrl = TextEditingController(text: widget.service.password); - _verbose = widget.service.verboseLog; - } - - @override - void dispose() { - _hostCtrl.dispose(); - _portCtrl.dispose(); - _userCtrl.dispose(); - _passCtrl.dispose(); - super.dispose(); - } - - void _onProtocolChanged(NymeaProtocol p) { - setState(() { - _protocol = p; - // Mettre à jour le port par défaut si l'utilisateur n'a pas changé - final currentPort = int.tryParse(_portCtrl.text) ?? 0; - final otherDefault = _defaultPorts[_protocol == NymeaProtocol.tcpRaw - ? NymeaProtocol.webSocket - : NymeaProtocol.tcpRaw]!; - if (currentPort == otherDefault || currentPort == 0) { - _portCtrl.text = _defaultPorts[p]!.toString(); - } - }); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Row( - children: [ - Container( - width: 36, height: 36, - decoration: BoxDecoration( - color: AppTheme.primaryGreen.withValues(alpha:0.12), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.router_rounded, - color: AppTheme.primaryGreen, size: 20), - ), - const SizedBox(width: 10), - const Text('Connexion Nymea'), ], ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - content: Column( - mainAxisSize: MainAxisSize.min, + ); + } +} + +// ─────────────────────────── Hero système ────────────────────────────────────── + +class _SystemHeroCard extends StatelessWidget { + final EnergyData data; + const _SystemHeroCard({required this.data}); + + String get _statusLabel { + if (data.gridPower.abs() < 50) return 'Optimal'; + return data.gridPower > 0 ? 'Soutirage réseau' : 'Injection réseau'; + } + + Color get _statusColor { + if (data.gridPower.abs() < 50) return EtmTokens.green; + return data.gridPower > 0 ? EtmTokens.orange : EtmTokens.blue; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, + ), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Sélecteur de protocole ───────────────────────────────────── - const Text('Protocole', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppTheme.textLight)), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - _ProtoTab( - label: 'TCP brut', - subtitle: 'port 2222', - icon: Icons.cable_rounded, - selected: _protocol == NymeaProtocol.tcpRaw, - onTap: () => _onProtocolChanged(NymeaProtocol.tcpRaw), - ), - _ProtoTab( - label: 'WebSocket', - subtitle: 'port 4444', - icon: Icons.wifi_rounded, - selected: _protocol == NymeaProtocol.webSocket, - onTap: () => _onProtocolChanged(NymeaProtocol.webSocket), - ), - ], - ), - ), - const SizedBox(height: 6), - // Info protocole - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryGreen.withValues(alpha:0.07), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _protocol == NymeaProtocol.tcpRaw - ? '💡 TCP brut : nymea envoie un message de bienvenue dès la connexion. Utilisé par l\'app nymea officielle.' - : '💡 WebSocket : le client envoie JSONRPC.Hello en premier. Activez-le dans nymea → Paramètres → Serveur WebSocket.', - style: const TextStyle(fontSize: 11, color: AppTheme.textLight), - ), - ), - const SizedBox(height: 14), - - // ── Hôte ────────────────────────────────────────────────────── - TextField( - controller: _hostCtrl, - decoration: const InputDecoration( - labelText: 'Adresse IP / Hôte', - prefixIcon: Icon(Icons.dns_outlined), - border: OutlineInputBorder(), - isDense: true, - ), - ), - const SizedBox(height: 10), - - // ── Port ────────────────────────────────────────────────────── - TextField( - controller: _portCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Port', - prefixIcon: Icon(Icons.settings_ethernet), - border: OutlineInputBorder(), - isDense: true, - ), - ), - const SizedBox(height: 10), - - // ── Identifiants ────────────────────────────────────────── - TextField( - controller: _userCtrl, - decoration: const InputDecoration( - labelText: 'Utilisateur', - prefixIcon: Icon(Icons.person_outline), - border: OutlineInputBorder(), - isDense: true, - ), - ), - const SizedBox(height: 10), - TextField( - controller: _passCtrl, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Mot de passe', - prefixIcon: Icon(Icons.lock_outline), - border: OutlineInputBorder(), - isDense: true, - ), - ), - - // ── Debug verbose ───────────────────────────────────────── - const SizedBox(height: 8), - Row( - children: [ - const Icon(Icons.terminal_rounded, size: 16, - color: AppTheme.textLight), - const SizedBox(width: 8), - const Expanded( - child: Text('Logs JSON verbose', - style: TextStyle(fontSize: 13, color: AppTheme.textDark)), - ), - Switch( - value: _verbose, - activeThumbColor: AppTheme.primaryGreen, - onChanged: (v) { - setState(() => _verbose = v); - widget.service.verboseLog = v; - }, - ), - ], - ), - - // ── Erreur ──────────────────────────────────────────────────── - if (widget.service.connectionError != null) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(8), - ), - child: Row( + // Left — status + metrics + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 22, 12, 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 16), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.service.connectionError!, - style: const TextStyle(color: Colors.red, fontSize: 11), + Row( + children: [ + _StatusPill(label: _statusLabel, color: _statusColor), + const SizedBox(width: 10), + Text('État du système', + style: EtmTokens.sans( + size: 11, + color: EtmTokens.faint, + letterSpacing: 0.12)), + ], + ), + const SizedBox(height: 12), + Text.rich( + TextSpan( + text: 'Maison ', + style: EtmTokens.sans(size: 17, weight: FontWeight.w500), + children: [ + TextSpan( + text: '${data.selfConsumptionRate.toStringAsFixed(0)}%', + style: EtmTokens.sans(size: 17, weight: FontWeight.w700), + ), + const TextSpan(text: ' autonome'), + ], ), ), + const SizedBox(height: 4), + Text( + '${data.gridPower.abs().toStringAsFixed(0)} W soutiré du réseau', + style: EtmTokens.sans(size: 13, color: EtmTokens.muted), + ), + const SizedBox(height: 16), + Row( + children: [ + _Metric(label: 'Autocons.', value: '${data.selfConsumptionRate.toStringAsFixed(0)}%'), + const SizedBox(width: 20), + _Metric(label: 'Soutirage', value: '${data.gridPower.abs().toStringAsFixed(0)} W'), + const SizedBox(width: 20), + _Metric(label: 'Batterie', value: '${data.batterySOC.toStringAsFixed(0)}%'), + ], + ), ], ), ), - ], + ), + // Right — illustration maison + Container( + width: 120, + decoration: const BoxDecoration( + border: Border(left: BorderSide(color: EtmTokens.line)), + borderRadius: BorderRadius.only( + topRight: Radius.circular(EtmTokens.radiusLg), + bottomRight: Radius.circular(EtmTokens.radiusLg), + ), + ), + child: const _HouseIllustration(), + ), ], ), - actions: [ - // Mode démo - TextButton.icon( - icon: const Icon(Icons.science_outlined, size: 16), - label: const Text('Mode démo'), - onPressed: () { - widget.service.startSimulation(); - Navigator.pop(context); - }, + ); + } +} + +class _StatusPill extends StatelessWidget { + final String label; + final Color color; + const _StatusPill({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(99), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 6, height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 5), + Text(label, + style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: color)), + ], + ), + ); + } +} + +class _Metric extends StatelessWidget { + final String label; + final String value; + const _Metric({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + Text(value, style: EtmTokens.mono(size: 18, weight: FontWeight.w700)), + ], + ); + } +} + +// Illustration maison simplifiée en CustomPainter +class _HouseIllustration extends StatelessWidget { + const _HouseIllustration(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(10), + child: AspectRatio( + aspectRatio: 270 / 200, + child: CustomPaint(painter: _HousePainter()), + ), + ); + } +} + +class _HousePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final sx = size.width / 270; + final sy = size.height / 200; + Offset p(double x, double y) => Offset(x * sx, y * sy); + + final outlinePaint = Paint() + ..color = const Color(0xFF0D2B3B).withValues(alpha: 0.45) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + // Ground + canvas.drawOval(Rect.fromCenter(center: p(138, 176), width: 236 * sx, height: 24 * sy), + Paint()..color = const Color(0xFFE9EEF1)); + + // Front wall + canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), + Paint()..color = const Color(0xFFF6F9FB)); + canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), outlinePaint); + + // Side wall + final side = Path() + ..moveTo(p(170, 96).dx, p(170, 96).dy) + ..lineTo(p(200, 84).dx, p(200, 84).dy) + ..lineTo(p(200, 158).dx, p(200, 158).dy) + ..lineTo(p(170, 168).dx, p(170, 168).dy)..close(); + canvas.drawPath(side, Paint()..color = const Color(0xFFDDE6EC)); + canvas.drawPath(side, outlinePaint); + + // Roof front slope + final roofF = Path() + ..moveTo(p(58, 98).dx, p(58, 98).dy) + ..lineTo(p(118, 56).dx, p(118, 56).dy) + ..lineTo(p(170, 56).dx, p(170, 56).dy) + ..lineTo(p(122, 98).dx, p(122, 98).dy)..close(); + canvas.drawPath(roofF, Paint()..color = const Color(0xFF3A4A55)); + canvas.drawPath(roofF, outlinePaint); + + // PV panels + final pv = Path() + ..moveTo(p(70, 92).dx, p(70, 92).dy) + ..lineTo(p(120, 60).dx, p(120, 60).dy) + ..lineTo(p(162, 60).dx, p(162, 60).dy) + ..lineTo(p(116, 92).dx, p(116, 92).dy)..close(); + canvas.drawPath(pv, Paint()..color = const Color(0xFF173A52)); + // Amber sheen + canvas.drawPath(pv, + Paint()..color = const Color(0xFFFEC113).withValues(alpha: 0.10)..style = PaintingStyle.fill); + + // Wallbox (blue) + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy), + const Radius.circular(3)), + Paint()..color = const Color(0xFF31A3DD), + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy), + const Radius.circular(3)), + outlinePaint, + ); + + // Door + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(p(86, 128).dx, p(86, 128).dy, 22 * sx, 40 * sy), + const Radius.circular(2)), + Paint()..color = const Color(0xFFC98A4A), + ); + + // Window + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy), + const Radius.circular(2)), + Paint()..color = const Color(0xFFBFE2F3), + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy), + const Radius.circular(2)), + outlinePaint, + ); + + // Labels + void drawLabel(String text, Offset pos) { + final tp = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + fontSize: 7, + fontWeight: FontWeight.w600, + color: Color(0xFF6B7D88), + letterSpacing: 0.5), ), - // Connecter - ElevatedButton.icon( - icon: _connecting - ? const SizedBox( - width: 14, height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.link_rounded, size: 16), - label: const Text('Connecter'), - onPressed: _connecting - ? null - : () async { - setState(() => _connecting = true); - final ok = await widget.service.connect( - _hostCtrl.text.trim(), - int.tryParse(_portCtrl.text.trim()) ?? - _defaultPorts[_protocol]!, - protocol: _protocol, - username: _userCtrl.text.trim(), - password: _passCtrl.text, - ); - if (ok && context.mounted) Navigator.pop(context); - if (mounted) setState(() => _connecting = false); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryGreen, - foregroundColor: Colors.white, - ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, pos); + } + + drawLabel('PV', p(150, 32)); + drawLabel('BORNE', p(34, 127)); + } + + @override + bool shouldRepaint(_HousePainter _) => false; +} + +// ─────────────────────────── KPI 2×2 ────────────────────────────────────────── + +class _KpiGrid extends StatelessWidget { + final EnergyData data; + const _KpiGrid({required this.data}); + + @override + Widget build(BuildContext context) { + final prodKwh = (data.dayProductionWh / 1000); + return Column( + children: [ + Row( + children: [ + Expanded(child: _KpiCard( + title: 'Production', + value: prodKwh.toStringAsFixed(1), + unit: ' kWh', + chart: _SparkBars(values: const [18, 12, 15, 22, 40, 55, 70, 85, 100, 78, 60, 42]), + )), + const SizedBox(width: 14), + Expanded(child: _KpiCard( + title: 'Autonomie', + value: data.autonomyRate.toStringAsFixed(0), + unit: '%', + color: EtmTokens.blue, + chart: _ProgressBar(value: data.autonomyRate / 100, color: EtmTokens.blue), + note: 'Objectif : 80%', + )), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded(child: _KpiCard( + title: 'Économie', + value: data.dayGains.toStringAsFixed(2), + unit: ' €', + color: EtmTokens.green, + chart: _TrendLine(), + note: 'vs maison non pilotée', + )), + const SizedBox(width: 14), + Expanded(child: _KpiCard( + title: 'Soutirage', + value: (data.dayGridInjectionWh.abs() / 1000).toStringAsFixed(1), + unit: ' kWh', + chart: _SparkBars( + values: const [30, 55, 40, 20, 8, 5, 6, 10], + color: EtmTokens.faint), + note: '0.42 €', + )), + ], ), ], ); } } -class _ProtoTab extends StatelessWidget { - final String label; - final String subtitle; - final IconData icon; - final bool selected; - final VoidCallback onTap; +class _KpiCard extends StatelessWidget { + final String title; + final String value; + final String unit; + final Color color; + final Widget chart; + final String? note; - const _ProtoTab({ - required this.label, - required this.subtitle, - required this.icon, - required this.selected, - required this.onTap, + const _KpiCard({ + required this.title, + required this.value, + required this.unit, + this.color = EtmTokens.navy, + required this.chart, + this.note, }); @override Widget build(BuildContext context) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.all(4), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: selected ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(8), - boxShadow: selected - ? [BoxShadow( - color: Colors.black.withValues(alpha:0.08), - blurRadius: 4, offset: const Offset(0, 1))] - : null, + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radius), + boxShadow: EtmTokens.cardShadow, + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w500)), + const SizedBox(height: 6), + Text.rich( + TextSpan( + text: value, + style: EtmTokens.mono(size: 26, weight: FontWeight.w700, color: color), + children: [ + TextSpan( + text: unit, + style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w600), + ), + ], + ), ), - child: Column( - children: [ - Icon(icon, - size: 20, - color: selected ? AppTheme.primaryGreen : AppTheme.textLight), - const SizedBox(height: 4), - Text(label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: selected ? AppTheme.primaryGreen : AppTheme.textLight)), - Text(subtitle, - style: const TextStyle( - fontSize: 10, color: AppTheme.textLight)), - ], + const SizedBox(height: 8), + chart, + if (note != null) ...[ + const SizedBox(height: 6), + Text(note!, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + ], + ], + ), + ); + } +} + +class _SparkBars extends StatelessWidget { + final List values; + final Color color; + const _SparkBars({required this.values, this.color = EtmTokens.amber}); + + @override + Widget build(BuildContext context) { + final max = values.reduce((a, b) => a > b ? a : b); + return SizedBox( + height: 34, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: values.map((v) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: FractionallySizedBox( + heightFactor: v / max, + alignment: Alignment.bottomCenter, + child: DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(2), + ), + ), + ), ), + )).toList(), + ), + ); + } +} + +class _ProgressBar extends StatelessWidget { + final double value; + final Color color; + const _ProgressBar({required this.value, required this.color}); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(99), + child: SizedBox( + height: 7, + child: LinearProgressIndicator( + value: value, + backgroundColor: EtmTokens.line, + valueColor: AlwaysStoppedAnimation(color), ), ), ); } -} \ No newline at end of file +} + +class _TrendLine extends StatelessWidget { + const _TrendLine(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: CustomPaint(painter: _TrendPainter()), + ); + } +} + +class _TrendPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final points = [34.0, 30.0, 31.0, 24.0, 22.0, 14.0, 11.0, 3.0]; + final w = size.width; + final h = size.height; + final dx = w / (points.length - 1); + final max = points.reduce((a, b) => a > b ? a : b); + + final pts = List.generate(points.length, + (i) => Offset(i * dx, h - (points[i] / max) * (h - 4))); + + // Fill + final fill = Path()..moveTo(pts.first.dx, h); + for (final p in pts) { + fill.lineTo(p.dx, p.dy); + } + fill.lineTo(pts.last.dx, h); + fill.close(); + canvas.drawPath( + fill, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + EtmTokens.green.withValues(alpha: 0.25), + EtmTokens.green.withValues(alpha: 0), + ], + ).createShader(Rect.fromLTWH(0, 0, w, h))); + + // Line + final linePaint = Paint() + ..color = EtmTokens.green + ..strokeWidth = 2.2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + final path = Path()..moveTo(pts.first.dx, pts.first.dy); + for (final p in pts.skip(1)) { + path.lineTo(p.dx, p.dy); + } + canvas.drawPath(path, linePaint); + } + + @override + bool shouldRepaint(_TrendPainter _) => false; +} + +// ─────────────────────────── Consommateurs ───────────────────────────────────── + +class _ConsumersCard extends StatelessWidget { + final EnergyData data; + const _ConsumersCard({required this.data}); + + @override + Widget build(BuildContext context) { + final total = data.homePower.clamp(1.0, double.infinity); + final consumers = [ + _Consumer('PAC', Icons.heat_pump_outlined, EtmTokens.blue, + EtmTokens.blueSoft, total * 0.40), + _Consumer('EVSE', Icons.ev_station_rounded, EtmTokens.green, + EtmTokens.greenSoft, data.chargingPower * 1000), + _Consumer('Chauffe-eau', Icons.water_drop_outlined, EtmTokens.amber, + EtmTokens.amberSoft, total * 0.18), + ]; + + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CardHeader('Consommateurs principaux', link: 'Voir tout'), + const SizedBox(height: 4), + ...consumers.map((c) => _ConsumerRow(c: c, total: total)), + ], + ), + ); + } +} + +class _Consumer { + final String name; + final IconData icon; + final Color color; + final Color bgColor; + final double watts; + const _Consumer(this.name, this.icon, this.color, this.bgColor, this.watts); +} + +class _ConsumerRow extends StatelessWidget { + final _Consumer c; + final double total; + const _ConsumerRow({required this.c, required this.total}); + + @override + Widget build(BuildContext context) { + final pct = (c.watts / total).clamp(0.0, 1.0); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Container( + width: 34, height: 34, + decoration: BoxDecoration(color: c.bgColor, borderRadius: BorderRadius.circular(10)), + child: Icon(c.icon, color: c.color, size: 18), + ), + const SizedBox(width: 12), + Expanded(child: Text(c.name, style: EtmTokens.sans(size: 14, weight: FontWeight.w500))), + Text('${c.watts.toStringAsFixed(0)} W', + style: EtmTokens.mono(size: 13, color: EtmTokens.muted)), + const SizedBox(width: 12), + SizedBox( + width: 70, + child: ClipRRect( + borderRadius: BorderRadius.circular(99), + child: SizedBox( + height: 6, + child: LinearProgressIndicator( + value: pct, + backgroundColor: EtmTokens.line, + valueColor: AlwaysStoppedAnimation(c.color), + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 36, + child: Text('${(pct * 100).toStringAsFixed(0)}%', + style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: c.color), + textAlign: TextAlign.right), + ), + ], + ), + ); + } +} + +// ─────────────────────────── Décisions Héos ──────────────────────────────────── + +class _HeosDecisionsCard extends StatelessWidget { + const _HeosDecisionsCard(); + + @override + Widget build(BuildContext context) { + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CardHeader('Décisions d\'Héos', link: 'Voir tout'), + const SizedBox(height: 4), + _DecisionRow( + icon: Icons.ev_station_rounded, + bgColor: EtmTokens.greenSoft, + iconColor: EtmTokens.green, + title: 'Charge voiture décalée', + time: '02h – 06h', + tags: [_Tag.tarif, _Tag.pv], + ), + _DecisionRow( + icon: Icons.heat_pump_outlined, + bgColor: EtmTokens.blueSoft, + iconColor: EtmTokens.blue, + title: 'PAC optimisée', + time: '06h30 – 08h30', + tags: [_Tag.heuresCreuses, _Tag.confort], + ), + _DecisionRow( + icon: Icons.battery_charging_full_rounded, + bgColor: EtmTokens.greenSoft, + iconColor: EtmTokens.green, + title: 'Charge batterie planifiée', + time: '12h – 15h', + tags: [_Tag.pv, _Tag.pic], + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Impact consolidé aujourd\'hui', + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), + Text.rich(TextSpan( + text: '4,60 € ', + style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: EtmTokens.greenDark), + children: [ + TextSpan(text: 'économisés', + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), + ], + )), + ], + ), + ), + ], + ), + ); + } +} + +enum _Tag { tarif, pv, heuresCreuses, confort, pic } + +class _DecisionRow extends StatelessWidget { + final IconData icon; + final Color bgColor; + final Color iconColor; + final String title; + final String time; + final List<_Tag> tags; + + const _DecisionRow({ + required this.icon, + required this.bgColor, + required this.iconColor, + required this.title, + required this.time, + required this.tags, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, height: 34, + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), + Text(time, style: EtmTokens.mono(size: 12, color: EtmTokens.muted)), + const SizedBox(height: 6), + Wrap( + spacing: 5, runSpacing: 5, + children: tags.map(_buildTag).toList(), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTag(_Tag tag) { + final (label, bg, fg) = switch (tag) { + _Tag.tarif => ('Tarif bas', EtmTokens.amberSoft, const Color(0xFF9A7510)), + _Tag.pv => ('Surplus PV', EtmTokens.greenSoft, EtmTokens.greenDark), + _Tag.heuresCreuses=> ('Heures creuses',EtmTokens.amberSoft, const Color(0xFF9A7510)), + _Tag.confort => ('Confort', const Color(0xFFEEF2F5), EtmTokens.muted), + _Tag.pic => ('Couvre le pic', EtmTokens.blueSoft, const Color(0xFF1F6F97)), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)), + child: Text(label, style: EtmTokens.sans(size: 11, weight: FontWeight.w500, color: fg)), + ); + } +} + +// ─────────────────────────── Prévisions ──────────────────────────────────────── + +class _ForecastCard extends StatelessWidget { + const _ForecastCard(); + + @override + Widget build(BuildContext context) { + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Prévisions & recommandations', + style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), + Text('aujourd\'hui', style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: EtmTokens.bg, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Prévisions non disponibles', + style: EtmTokens.sans(size: 14, weight: FontWeight.w500, color: EtmTokens.muted)), + const SizedBox(height: 4), + Text('Configurez un provider dans Tarifs & Héos pour activer les prévisions PV et tarifaires.', + style: EtmTokens.sans(size: 12, color: EtmTokens.faint)), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ─────────────────────────── Widgets partagés ────────────────────────────────── + +class _Card extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + const _Card({required this.child, this.padding}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, + ), + padding: padding ?? const EdgeInsets.all(20), + child: child, + ); + } +} + +class _CardHeader extends StatelessWidget { + final String title; + final String? link; + const _CardHeader(this.title, {this.link}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), + if (link != null) + Text(link!, style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: EtmTokens.blue)), + ], + ); + } +} diff --git a/lib/screens/drawer/main_drawer.dart b/lib/screens/drawer/main_drawer.dart index 663b8c4..7bd3972 100644 --- a/lib/screens/drawer/main_drawer.dart +++ b/lib/screens/drawer/main_drawer.dart @@ -4,10 +4,8 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../providers/installer_mode_provider.dart'; import '../../providers/navigation_provider.dart'; -import '../../providers/app_settings_provider.dart'; import '../../services/nymea_service.dart'; -import '../../theme/app_theme.dart'; -import '../../theme/etm_theme.dart'; +import '../../theme/etm_tokens.dart'; import 'installer_pin_dialog.dart'; // ───────────────────────────────────────────────────────────────────────────── @@ -38,9 +36,9 @@ class DrawerPanel extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: ETMTheme.drawerWidth, + width: 320, child: Material( - color: ETMTheme.drawerBackground, + color: EtmTokens.navy, child: SafeArea( child: Column( children: [ @@ -66,13 +64,12 @@ class _DrawerHeader extends StatelessWidget { final installer = context.watch(); return Container( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + padding: const EdgeInsets.fromLTRB(20, 18, 16, 18), decoration: BoxDecoration( - color: ETMTheme.drawerSurface, - border: Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.08), - ), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [EtmTokens.navy, EtmTokens.navy2], ), ), child: Column( @@ -84,14 +81,12 @@ class _DrawerHeader extends StatelessWidget { Container( width: 44, height: 44, decoration: BoxDecoration( - color: ETMTheme.accentColor.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.bolt_rounded, - color: Colors.white, - size: 26, + borderRadius: BorderRadius.circular(13), + gradient: const LinearGradient( + colors: [EtmTokens.blue, Color(0xFF1F7FB3)], + ), ), + child: const Icon(Icons.bolt, color: Colors.white, size: 24), ), const SizedBox(width: 10), Expanded( @@ -110,15 +105,11 @@ class _DrawerHeader extends StatelessWidget { service.isSimulation ? 'Mode simulation' : (service.host), - style: TextStyle( - color: ETMTheme.drawerTextMuted, - fontSize: 11, - ), + style: EtmTokens.sans(size: 12, color: const Color(0xFFA9C4D3)), ), ], ), ), - // Indicateur de connexion _ConnectionDot( connected: service.connected, simulation: service.isSimulation, @@ -131,11 +122,7 @@ class _DrawerHeader extends StatelessWidget { // ── Nom du site ────────────────────────────────────────────────── Text( service.isSimulation ? 'Site démo' : 'Mon installation', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - ), + style: EtmTokens.sans(size: 15, weight: FontWeight.w600, color: Colors.white), ), const SizedBox(height: 6), @@ -143,15 +130,11 @@ class _DrawerHeader extends StatelessWidget { // ── Utilisateur + badge rôle ───────────────────────────────────── Row( children: [ - Icon(Icons.person_outline_rounded, - size: 14, color: ETMTheme.drawerTextMuted), - const SizedBox(width: 5), + const Icon(Icons.person_outline, size: 16, color: Color(0xFFA9C4D3)), + const SizedBox(width: 6), Text( service.username.isNotEmpty ? service.username : 'Utilisateur', - style: TextStyle( - color: ETMTheme.drawerTextMuted, - fontSize: 12, - ), + style: EtmTokens.sans(size: 13, color: const Color(0xFFCFE6F3)), ), const SizedBox(width: 8), _RoleBadge(isInstaller: installer.isUnlocked), @@ -172,12 +155,12 @@ class _ConnectionDot extends StatelessWidget { @override Widget build(BuildContext context) { final Color color = simulation - ? Colors.orange + ? EtmTokens.amber : connected - ? AppTheme.primaryGreen - : AppTheme.boostRed; + ? EtmTokens.green + : EtmTokens.danger; return Container( - width: 10, height: 10, + width: 9, height: 9, decoration: BoxDecoration(shape: BoxShape.circle, color: color), ); } @@ -189,24 +172,17 @@ class _RoleBadge extends StatelessWidget { @override Widget build(BuildContext context) { + final color = isInstaller ? EtmTokens.orange : EtmTokens.blue; return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: isInstaller - ? ETMTheme.installerBadgeColor.withValues(alpha: 0.2) - : ETMTheme.accentColor.withValues(alpha: 0.2), + color: color.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.5)), ), child: Text( isInstaller ? 'INSTALLATEUR' : 'UTILISATEUR', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - color: isInstaller - ? ETMTheme.installerBadgeColor - : ETMTheme.accentColor, - ), + style: EtmTokens.sans(size: 10, weight: FontWeight.w700, color: color, letterSpacing: 0.5), ), ); } @@ -293,16 +269,8 @@ class _SectionLabel extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - text, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: ETMTheme.drawerTextMuted, - ), - ), + padding: const EdgeInsets.fromLTRB(20, 14, 20, 8), + child: Text(text, style: EtmTokens.sectionLabel()), ); } } @@ -312,9 +280,9 @@ class _Divider extends StatelessWidget { Widget build(BuildContext context) { return Divider( color: Colors.white.withValues(alpha: 0.08), - height: 1, - indent: 16, - endIndent: 16, + height: 16, + indent: 20, + endIndent: 20, ); } } @@ -370,8 +338,8 @@ class _LinkItem extends StatelessWidget { return _DrawerTile( icon: icon, label: label, - trailing: Icon(Icons.open_in_new_rounded, - size: 14, color: ETMTheme.drawerTextMuted), + trailing: const Icon(Icons.open_in_new_rounded, + size: 14, color: EtmTokens.faint), onTap: () async { context.read().closeDrawer(); final uri = Uri.parse(url); @@ -406,28 +374,19 @@ class _DrawerTile extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(10), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Icon(icon, size: 19, color: ETMTheme.drawerTextPrimary), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: TextStyle( - color: ETMTheme.drawerTextPrimary, - fontSize: 13.5, - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.92)), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: EtmTokens.sans(size: 14, color: Colors.white.withValues(alpha: 0.92)), ), - if (trailing != null) trailing!, - ], - ), + ), + if (trailing != null) trailing!, + ], ), ), ); @@ -486,17 +445,11 @@ class _StyledExpansion extends StatelessWidget { dividerColor: Colors.transparent, ), child: ExpansionTile( - leading: Icon(icon, size: 19, color: ETMTheme.drawerTextPrimary), - title: Text( - label, - style: TextStyle( - color: ETMTheme.drawerTextPrimary, - fontSize: 13.5, - ), - ), - iconColor: ETMTheme.drawerTextMuted, - collapsedIconColor: ETMTheme.drawerTextMuted, - tilePadding: const EdgeInsets.symmetric(horizontal: 22), + leading: Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.92)), + title: Text(label, style: EtmTokens.sans(size: 14, color: Colors.white.withValues(alpha: 0.92))), + iconColor: EtmTokens.faint, + collapsedIconColor: EtmTokens.faint, + tilePadding: const EdgeInsets.symmetric(horizontal: 20), childrenPadding: EdgeInsets.zero, children: children, ), @@ -517,14 +470,8 @@ class _SubItem extends StatelessWidget { context.push(route); }, child: Padding( - padding: const EdgeInsets.fromLTRB(54, 9, 16, 9), - child: Text( - label, - style: TextStyle( - color: ETMTheme.drawerTextMuted, - fontSize: 13, - ), - ), + padding: const EdgeInsets.fromLTRB(56, 10, 16, 10), + child: Text(label, style: EtmTokens.sans(size: 13, color: EtmTokens.faint)), ), ); } @@ -543,32 +490,23 @@ class _InstallerSection extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: InkWell( onTap: () => _promptPin(context), - borderRadius: BorderRadius.circular(10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: ETMTheme.installerBadgeColor.withValues(alpha: 0.3), - ), + color: EtmTokens.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.4)), ), child: Row( children: [ - Icon(Icons.build_rounded, - size: 19, color: ETMTheme.installerBadgeColor), - const SizedBox(width: 12), + Icon(Icons.build_outlined, size: 18, color: EtmTokens.orange), + const SizedBox(width: 10), Expanded( - child: Text( - '🔧 Mode Installateur', - style: TextStyle( - color: ETMTheme.installerBadgeColor, - fontSize: 13.5, - fontWeight: FontWeight.w600, - ), - ), + child: Text('Mode installateur', + style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.orange)), ), - Icon(Icons.lock_rounded, - size: 14, color: ETMTheme.drawerTextMuted), + Icon(Icons.lock_outline, size: 16, color: EtmTokens.orange), ], ), ), @@ -629,17 +567,24 @@ class _InstallerSection extends StatelessWidget { route: '/settings/system/about', push: true, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: AppTheme.boostRed, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + InkWell( + onTap: () => context.read().lock(), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: EtmTokens.danger.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: EtmTokens.danger.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Icon(Icons.lock_outline, size: 18, color: EtmTokens.danger), + const SizedBox(width: 10), + Text('Verrouiller mode installateur', + style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.danger)), + ], ), - icon: const Icon(Icons.lock_open_rounded, size: 16), - label: const Text('Verrouiller mode installateur'), - onPressed: () => - context.read().lock(), ), ), ], diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index b9aceb4..31e0fdf 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -6,25 +6,20 @@ 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; // ───────────────────────────────────────────────────────────────────────────── // EnergyScreen — historique énergétique // -// ① Line chart : Production · Consommation · Autoconsommation · Batterie -// ② Bar chart : Bilan Production vs Consommation par période -// -// Onglets : Heures (24 h, 15 min) · Jour (7 j, 1 h) · -// Semaine (4 sem, 1 j) · Mois (12 mois, 1 sem) +// 4 KPIs · sélecteur période · line chart double axe (kW / SOC%) · bar chart // ───────────────────────────────────────────────────────────────────────────── class _Tab { final String label; final Duration range; final String sampleRate; - final bool showTime; // true → HH:mm, false → DD/MM - + final bool showTime; const _Tab(this.label, this.range, this.sampleRate, {required this.showTime}); } @@ -45,17 +40,14 @@ class _EnergyScreenState extends State { int _tabIdx = 0; List _data = []; - List _socData = []; // historique SOC batterie (%) - bool _loading = true; // true dès le départ → spinner jusqu'au premier fetch - bool _noData = false; - DateTime? _selectedDate; // null = aujourd'hui (date courante) + List _socData = []; + bool _loading = true; + bool _noData = false; + DateTime? _selectedDate; Timer? _refreshTimer; - // Fix IndexedStack : initState de tous les écrans se déclenche au démarrage, - // avant que les things soient chargés → on ajoute un listener pour re-fetcher - // le SOC dès que les things deviennent disponibles. NymeaService? _nymeaService; - bool _initialSocFetched = false; // évite les re-fetch infinis via le listener + bool _initialSocFetched = false; @override void initState() { @@ -65,7 +57,6 @@ class _EnergyScreenState extends State { _nymeaService!.addListener(_onServiceChangedForSoc); _fetch(); }); - // Rafraîchit le graphe toutes les 5 minutes pour garder les données à jour _refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) { if (mounted) _fetch(); }); @@ -78,8 +69,6 @@ class _EnergyScreenState extends State { super.dispose(); } - /// Déclenche un fetch SOC silencieux quand les things deviennent disponibles - /// (cas où l'IndexedStack a construit l'écran avant la connexion nymea). void _onServiceChangedForSoc() { if (!mounted || _initialSocFetched || _loading) return; if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) { @@ -88,61 +77,38 @@ class _EnergyScreenState extends State { } } - /// Récupère uniquement l'historique SOC sans re-fetcher le bilan de puissance. Future _fetchSocOnly() async { if (!mounted) return; final tab = _tabs[_tabIdx]; - final to = _selectedDate != null - ? DateTime( - _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, - 23, 59, 59) - : DateTime.now(); + final to = _anchorDate(); final service = context.read(); final socSource = service.batterySOCSource; if (socSource == null) return; final soc = await service.fetchHistory( - thingId: socSource['thingId']!, + thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!, from: to.subtract(tab.range), - to: to, + to: to, sampleRate: tab.sampleRate, ); - if (!mounted) return; - if (soc.isNotEmpty) { - setState(() => _socData = soc); - } + if (mounted && soc.isNotEmpty) setState(() => _socData = soc); } Future _fetch() async { if (!mounted) return; setState(() { _loading = true; _noData = false; }); final tab = _tabs[_tabIdx]; - // Ancre : fin de la journée sélectionnée, ou maintenant si aucune date - final to = _selectedDate != null - ? DateTime( - _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, - 23, 59, 59) - : DateTime.now(); + final to = _anchorDate(); final from = to.subtract(tab.range); final service = context.read(); - // SOC indisponible en simulation (fetchHistory retourne des W, pas des %) - final socSource = service.batterySOCSource; // null si pas de batterie + final socSource = service.batterySOCSource; - // Récupère bilan de puissance et historique SOC en parallèle final results = await Future.wait([ - service.fetchPowerBalanceLogs( - from: from, - to: to, - sampleRate: tab.sampleRate, - ), + service.fetchPowerBalanceLogs(from: from, to: to, sampleRate: tab.sampleRate), socSource != null ? service.fetchHistory( - thingId: socSource['thingId']!, - stateTypeName: socSource['stateName']!, - from: from, - to: to, - sampleRate: tab.sampleRate, - ) + thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!, + from: from, to: to, sampleRate: tab.sampleRate) : Future>.value([]), ]); if (!mounted) return; @@ -154,6 +120,10 @@ class _EnergyScreenState extends State { }); } + DateTime _anchorDate() => _selectedDate != null + ? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59) + : DateTime.now(); + Future _pickDate() async { final picked = await showDatePicker( context: context, @@ -163,7 +133,7 @@ class _EnergyScreenState extends State { builder: (ctx, child) => Theme( data: Theme.of(ctx).copyWith( colorScheme: const ColorScheme.light( - primary: AppTheme.accentTeal, + primary: EtmTokens.green, onPrimary: Colors.white, ), ), @@ -181,268 +151,195 @@ class _EnergyScreenState extends State { return Consumer( builder: (context, service, _) { return Scaffold( - backgroundColor: AppTheme.backgroundGray, - appBar: AppBar( - backgroundColor: AppTheme.backgroundGray, - elevation: 0, - leading: const DrawerMenuButton(), - leadingWidth: 56, - title: const Text('Énergie', - style: TextStyle( - fontWeight: FontWeight.bold, color: AppTheme.textDark)), - actions: [ - IconButton( - icon: Icon( - Icons.calendar_today_rounded, - color: _selectedDate != null - ? AppTheme.accentTeal - : AppTheme.textDark, - ), - onPressed: _pickDate, - ), - IconButton( - icon: const Icon(Icons.refresh_rounded, - color: AppTheme.textDark), - onPressed: _fetch, - ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ── Tuiles résumé ─────────────────────────────────────────── - _SummaryRow(service: service), - const SizedBox(height: 16), - - // ── Sélecteur d'onglet ────────────────────────────────────── - _buildTabBar(), - const SizedBox(height: 8), - - // ── Date sélectionnée (chip dismissible) ──────────────────── - if (_selectedDate != null) - Align( - alignment: Alignment.centerLeft, - child: InkWell( - onTap: () { - setState(() => _selectedDate = null); - _fetch(); - }, - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.accentTeal.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppTheme.accentTeal, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.calendar_today_rounded, - size: 12, color: AppTheme.accentTeal), - const SizedBox(width: 5), - Text( - '${_selectedDate!.day.toString().padLeft(2, '0')}/' - '${_selectedDate!.month.toString().padLeft(2, '0')}/' - '${_selectedDate!.year}', - style: const TextStyle( - fontSize: 12, - color: AppTheme.accentTeal, - fontWeight: FontWeight.bold), - ), - const SizedBox(width: 5), - const Icon(Icons.close_rounded, - size: 12, color: AppTheme.accentTeal), - ], - ), - ), + backgroundColor: EtmTokens.bg, + body: CustomScrollView( + slivers: [ + // AppBar + SliverAppBar( + floating: true, + backgroundColor: EtmTokens.bg, + elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 64, + title: Text('Énergie', + style: EtmTokens.sans(size: 20, weight: FontWeight.w600)), + actions: [ + IconButton( + icon: Icon( + Icons.calendar_today_rounded, + color: _selectedDate != null ? EtmTokens.blue : EtmTokens.muted, + size: 20, + ), + onPressed: _pickDate, + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: const Icon(Icons.refresh_rounded, + color: EtmTokens.muted, size: 20), + onPressed: _fetch, ), ), - const SizedBox(height: 8), + ], + ), - // ── ① Line chart ──────────────────────────────────────────── - _ChartCard( - title: 'Puissances (W) · SOC %', - legend: const [ - _LegendItem(color: AppTheme.solarYellow, label: 'Production'), - _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), - _LegendItem(color: AppTheme.accentTeal, label: 'Autoconso'), - _LegendItem(color: AppTheme.batteryGreen, label: 'SOC %', dashed: true), - ], - child: SizedBox( - height: 200, - child: _loading - ? _spinner() - : _noData - ? _empty() - : _buildLineChart(), - ), - ), - const SizedBox(height: 12), + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // KPI 2x2 + _KpiSection(service: service), + const SizedBox(height: 16), - // ── ② Bar chart ───────────────────────────────────────────── - _ChartCard( - title: 'Bilan énergétique (Wh)', - legend: const [ - _LegendItem(color: AppTheme.solarYellow, label: 'Production'), - _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), - ], - child: SizedBox( - height: 180, - child: _loading - ? _spinner() - : _noData - ? _empty() - : _buildBarChart(), - ), + // Sélecteur période + _PeriodSelector( + tabs: _tabs, + selectedIndex: _tabIdx, + onSelect: (i) { + if (_tabIdx != i) { + setState(() { _tabIdx = i; _initialSocFetched = false; }); + _fetch(); + } + }, + ), + + // Chip date sélectionnée + if (_selectedDate != null) ...[ + const SizedBox(height: 10), + _DateChip( + date: _selectedDate!, + onClear: () { + setState(() => _selectedDate = null); + _fetch(); + }, + ), + ], + const SizedBox(height: 16), + + // Graphe Puissances · SOC % + _EtmChartCard( + title: 'Puissances · SOC %', + subtitle: 'kW (gauche) · % batterie (droite)', + legend: [ + _LegendDot(EtmTokens.amber, 'Production'), + _LegendDot(EtmTokens.blue, 'Consommation'), + _LegendDot(EtmTokens.green, 'Autoconso'), + _LegendDot(EtmTokens.green, 'SOC %', dashed: true), + ], + height: 220, + loading: _loading, + noData: _noData, + child: _buildLineChart(), + ), + const SizedBox(height: 14), + + // Graphe Bilan énergétique + _EtmChartCard( + title: 'Bilan énergétique (Wh)', + subtitle: 'Énergie par période', + legend: [ + _LegendDot(EtmTokens.amber, 'Production'), + _LegendDot(EtmTokens.blue, 'Consommation'), + ], + height: 200, + loading: _loading, + noData: _noData, + child: _buildBarChart(), + ), + const SizedBox(height: 14), + + // Météo & prévision — placeholder + _ForecastPlaceholder(), + ]), ), - ], - ), + ), + ], ), ); }, ); } - // ── Tab bar ─────────────────────────────────────────────────────────────── - - Widget _buildTabBar() { - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: AppTheme.cardWhite, - borderRadius: BorderRadius.circular(AppTheme.cornerRadius), - ), - child: Row( - children: _tabs.asMap().entries.map((e) { - final sel = _tabIdx == e.key; - return Expanded( - child: GestureDetector( - onTap: () { - if (_tabIdx != e.key) { - setState(() { _tabIdx = e.key; _initialSocFetched = false; }); - _fetch(); - } - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: - sel ? AppTheme.accentTeal : Colors.transparent, - borderRadius: - BorderRadius.circular(AppTheme.cornerRadius - 2), - ), - child: Text( - e.value.label, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: - sel ? FontWeight.bold : FontWeight.normal, - color: sel ? Colors.white : AppTheme.textLight, - ), - ), - ), - ), - ); - }).toList(), - ), - ); - } - - // ── ① Line chart ───────────────────────────────────────────────────────── + // ── ① Line chart : puissances + SOC ──────────────────────────────────────── Widget _buildLineChart() { - if (_data.isEmpty) return _empty(); + if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); + final prodSpots = []; final consoSpots = []; final autoSpots = []; - final socSpots = []; // SOC % mis à l'échelle des W + final socSpots = []; for (int i = 0; i < _data.length; i++) { final d = _data[i]; final x = i.toDouble(); - prodSpots .add(FlSpot(x, d.productionW)); - consoSpots.add(FlSpot(x, d.consumptionW)); - autoSpots .add(FlSpot(x, d.autoconsommationW)); + // Toujours en kW pour l'axe gauche + prodSpots .add(FlSpot(x, d.productionW / 1000)); + consoSpots.add(FlSpot(x, d.consumptionW / 1000)); + autoSpots .add(FlSpot(x, d.autoconsommationW / 1000)); } - // min/max sur production + consommation (toujours ≥ 0) - final allY = _data.expand((d) => [d.productionW, d.consumptionW]).toList(); - final minY = allY.isEmpty ? 0.0 : allY.reduce(min); - final maxY = allY.isEmpty ? 1000.0 : allY.reduce(max); - final spread = (maxY - minY) > 0 ? maxY - minY : 200.0; - final yPad = spread * 0.12; + // Plage Y gauche en kW + final allKw = _data.expand((d) => [d.productionW / 1000, d.consumptionW / 1000]).toList(); + final maxKw = allKw.isEmpty ? 2.5 : allKw.reduce(max); + final yMax = max(maxKw * 1.15, 0.5); // min 0.5 kW pour éviter y écrasé - // Facteur d'échelle : SOC 100 % → y = socMaxW (sommet du graphe) - final socMaxW = max(maxY + yPad, 100.0); - // SOC disponible si on a au moins 2 points (pour interpoler sur l'axe X) + // SOC normalisé sur l'échelle kW (100% → yMax) final hasSoc = _socData.length > 1; - if (hasSoc) { - final n = _socData.length; + final n = _socData.length; final xMax = (_data.length - 1).toDouble(); for (int i = 0; i < n; i++) { - // Normalise l'index SOC sur la plage X du power chart - final x = xMax * i / (n - 1); - final scaled = _socData[i].value * socMaxW / 100.0; - socSpots.add(FlSpot(x, scaled)); + socSpots.add(FlSpot( + xMax * i / (n - 1), + _socData[i].value * yMax / 100.0, + )); } } final xInterval = _xInterval(); + final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted); return LineChart( LineChartData( clipData: const FlClipData.all(), - minY: minY - yPad, - maxY: socMaxW, + minY: 0, + maxY: yMax, gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (_) => - const FlLine(color: Color(0xFFEEEEEE), strokeWidth: 1), + FlLine(color: EtmTokens.line, strokeWidth: 1), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( + axisNameWidget: Text('kW', style: EtmTokens.sans(size: 9, color: EtmTokens.muted)), + axisNameSize: 14, sideTitles: SideTitles( showTitles: true, - reservedSize: 50, - getTitlesWidget: (v, _) => Padding( - padding: const EdgeInsets.only(right: 4), - child: Text(_fmtW(v), - style: const TextStyle( - fontSize: 8.5, color: AppTheme.textLight), - textAlign: TextAlign.right), + reservedSize: 42, + getTitlesWidget: (v, _) => Text( + v.toStringAsFixed(1), + style: labelStyle, + textAlign: TextAlign.right, ), ), ), - // Axe Y droit : SOC % (affiché seulement si données SOC disponibles) rightTitles: hasSoc ? AxisTitles( + axisNameWidget: Text('%', style: EtmTokens.sans(size: 9, color: EtmTokens.green)), + axisNameSize: 14, sideTitles: SideTitles( showTitles: true, - reservedSize: 36, - // interval : socMaxW / 4 → labels à 0 / 25 / 50 / 75 / 100 % - interval: socMaxW / 4, + reservedSize: 34, + interval: yMax / 4, getTitlesWidget: (v, _) { - final pct = (v / socMaxW * 100).round(); + final pct = (v / yMax * 100).round(); if (pct < 0 || pct > 100) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 4), - child: Text('$pct%', - style: const TextStyle( - fontSize: 8.5, - color: AppTheme.batteryGreen), - textAlign: TextAlign.left), - ); + return Text('$pct%', + style: EtmTokens.sans(size: 9, color: EtmTokens.green)); }, ), ) @@ -450,41 +347,36 @@ class _EnergyScreenState extends State { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 18, + reservedSize: 20, interval: xInterval, getTitlesWidget: (v, _) { final idx = v.round(); - if (idx < 0 || idx >= _data.length) { - return const SizedBox.shrink(); - } - return Text(_fmtTime(_data[idx].timestamp), - style: const TextStyle( - fontSize: 8.5, color: AppTheme.textLight)); + if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle), + ); }, ), ), ), lineBarsData: [ - _lineSeries(prodSpots, AppTheme.solarYellow), // index 0 - _lineSeries(consoSpots, AppTheme.homeBlue), // index 1 - _lineSeries(autoSpots, AppTheme.accentTeal), // index 2 - if (hasSoc) - _lineSeries(socSpots, AppTheme.batteryGreen, dashed: true), // index 3 + _line(prodSpots, EtmTokens.amber), + _line(consoSpots, EtmTokens.blue), + _line(autoSpots, EtmTokens.green), + if (hasSoc) _line(socSpots, EtmTokens.green, dashed: true, width: 1.5), ], lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85), getTooltipItems: (spots) => spots.map((s) { - // Série index 3 = SOC → tooltip en % final isSoc = hasSoc && s.barIndex == 3; final label = isSoc - ? '${(s.y / socMaxW * 100).toStringAsFixed(0)} %' - : _fmtW(s.y); + ? '${(s.y / yMax * 100).toStringAsFixed(0)} %' + : '${s.y.toStringAsFixed(2)} kW'; return LineTooltipItem( label, - TextStyle( - color: s.bar.color ?? Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold), + EtmTokens.mono(size: 11, color: s.bar.color ?? Colors.white), ); }).toList(), ), @@ -493,83 +385,70 @@ class _EnergyScreenState extends State { ); } - LineChartBarData _lineSeries(List spots, Color color, - {bool dashed = false}) => + LineChartBarData _line(List spots, Color color, + {bool dashed = false, double width = 2.0}) => LineChartBarData( spots: spots, isCurved: true, - curveSmoothness: 0.2, + curveSmoothness: 0.25, color: color, - barWidth: dashed ? 1.5 : 2, + barWidth: width, dotData: const FlDotData(show: false), - dashArray: dashed ? [4, 4] : null, + dashArray: dashed ? [4, 5] : null, belowBarData: BarAreaData( show: !dashed, color: color.withValues(alpha: 0.07), ), ); - // ── ② Bar chart ────────────────────────────────────────────────────────── + // ── ② Bar chart : bilan énergétique ──────────────────────────────────────── Widget _buildBarChart() { - // Énergie par période = delta des cumuls entre entrées successives + if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); + final groups = []; double maxE = 1.0; + final bw = _barWidth(); - // n barres (même étendue que le line chart x=0..n-1) - // barre[i] = énergie de la période [data[i-1] … data[i]] - // barre[0] = 0 (pas de période précédente) for (int i = 0; i < _data.length; i++) { - final prodWh = i == 0 - ? 0.0 - : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh); - final consoWh = i == 0 - ? 0.0 - : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh); + final prodWh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh); + final consoWh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh); maxE = [maxE, prodWh, consoWh].reduce(max); - final bw = _barWidth(); groups.add(BarChartGroupData( x: i, barsSpace: 2, barRods: [ BarChartRodData( - toY: prodWh, - color: AppTheme.solarYellow, - width: bw, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(3)), + toY: prodWh, color: EtmTokens.amber, width: bw, + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), BarChartRodData( - toY: consoWh, - color: AppTheme.homeBlue, - width: bw, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(3)), + toY: consoWh, color: EtmTokens.blue, width: bw, + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), ], )); } + final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted); + return BarChart( BarChartData( maxY: maxE * 1.15, alignment: BarChartAlignment.spaceAround, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, groupIdx, rod, rodIdx) => BarTooltipItem( + getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85), + getTooltipItem: (group, _, rod, __) => BarTooltipItem( _fmtWh(rod.toY), - TextStyle( - color: rod.color ?? Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold), + EtmTokens.mono(size: 10, color: rod.color ?? Colors.white), ), ), ), gridData: FlGridData( show: true, drawVerticalLine: false, - getDrawingHorizontalLine: (_) => - const FlLine(color: Color(0xFFEEEEEE), strokeWidth: 1), + getDrawingHorizontalLine: (_) => FlLine(color: EtmTokens.line, strokeWidth: 1), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( @@ -578,32 +457,24 @@ class _EnergyScreenState extends State { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 50, - getTitlesWidget: (v, _) => Padding( - padding: const EdgeInsets.only(right: 4), - child: Text(_fmtWh(v), - style: const TextStyle( - fontSize: 8.5, color: AppTheme.textLight), - textAlign: TextAlign.right), - ), + reservedSize: 46, + getTitlesWidget: (v, _) => Text(_fmtWh(v), + style: labelStyle, textAlign: TextAlign.right), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 18, - // Pas d'interval ici : fl_chart passe l'entier exact de chaque - // groupe → on filtre manuellement avec le même pas que le line chart + reservedSize: 20, getTitlesWidget: (v, _) { - final idx = v.toInt(); - if (idx < 0 || idx >= _data.length) { - return const SizedBox.shrink(); - } + final idx = v.toInt(); + if (idx < 0 || idx >= _data.length) return const SizedBox.shrink(); final step = _xInterval().toInt().clamp(1, _data.length); if (idx % step != 0) return const SizedBox.shrink(); - return Text(_fmtTime(_data[idx].timestamp), - style: const TextStyle( - fontSize: 8.5, color: AppTheme.textLight)); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle), + ); }, ), ), @@ -621,94 +492,304 @@ class _EnergyScreenState extends State { double _barWidth() => _data.length > 40 ? 3 : _data.length > 20 ? 5 : 8; - String _fmtW(double v) { - if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}kW'; - return '${v.toStringAsFixed(0)}W'; - } - String _fmtWh(double v) { - if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}kWh'; - return '${v.toStringAsFixed(0)}Wh'; + if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)} kWh'; + return '${v.toStringAsFixed(0)} Wh'; } - String _fmtTime(DateTime t) { - if (_tabs[_tabIdx].showTime) { - return '${t.hour.toString().padLeft(2, '0')}:' - '${t.minute.toString().padLeft(2, '0')}'; - } - return '${t.day}/${t.month}'; - } - - Widget _spinner() => const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, color: AppTheme.accentTeal), - ), - ); - - Widget _empty() => const Center( - child: Text('Aucune donnée', - style: TextStyle(color: AppTheme.textLight, fontSize: 12)), - ); + String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime + ? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}' + : '${t.day}/${t.month}'; } -// ───────────────────────────────────────────────────────────────────────────── -// Widgets helpers -// ───────────────────────────────────────────────────────────────────────────── +// ─────────────────────────── KPI section ─────────────────────────────────────── -class _ChartCard extends StatelessWidget { +class _KpiSection extends StatelessWidget { + final NymeaService service; + const _KpiSection({required this.service}); + + @override + Widget build(BuildContext context) { + final d = service.energyData; + return Column( + children: [ + Row( + children: [ + _KpiTile( + icon: Icons.wb_sunny_rounded, + color: EtmTokens.amber, + bgColor: EtmTokens.amberSoft, + label: 'Production', + value: _kwh(d.dayProductionWh), + ), + const SizedBox(width: 14), + _KpiTile( + icon: Icons.home_rounded, + color: EtmTokens.blue, + bgColor: EtmTokens.blueSoft, + label: 'Consommation', + value: _kwh(d.daySelfConsumptionWh), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + _KpiTile( + icon: Icons.recycling_rounded, + color: EtmTokens.green, + bgColor: EtmTokens.greenSoft, + label: 'Autoconsommation', + value: '${d.selfConsumptionRate.toStringAsFixed(0)}%', + ), + const SizedBox(width: 14), + _KpiTile( + icon: Icons.euro_rounded, + color: EtmTokens.greenDark, + bgColor: EtmTokens.greenSoft, + label: 'Gains', + value: '${d.dayGains.toStringAsFixed(2)} €', + ), + ], + ), + ], + ); + } + + static String _kwh(double wh) => '${(wh / 1000).toStringAsFixed(2)} kWh'; +} + +class _KpiTile extends StatelessWidget { + final IconData icon; + final Color color; + final Color bgColor; + final String label; + final String value; + + const _KpiTile({ + required this.icon, + required this.color, + required this.bgColor, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radius), + boxShadow: EtmTokens.cardShadow, + ), + child: Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + const SizedBox(height: 2), + Text(value, + style: EtmTokens.mono(size: 15, weight: FontWeight.w700, color: color), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ─────────────────────────── Sélecteur période ───────────────────────────────── + +class _PeriodSelector extends StatelessWidget { + final List<_Tab> tabs; + final int selectedIndex; + final ValueChanged onSelect; + + const _PeriodSelector({ + required this.tabs, + required this.selectedIndex, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radius), + boxShadow: EtmTokens.cardShadow, + ), + child: Row( + children: tabs.asMap().entries.map((e) { + final selected = selectedIndex == e.key; + return Expanded( + child: GestureDetector( + onTap: () => onSelect(e.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: selected ? EtmTokens.green : Colors.transparent, + borderRadius: BorderRadius.circular(EtmTokens.radius - 4), + ), + child: Text( + e.value.label, + textAlign: TextAlign.center, + style: EtmTokens.sans( + size: 13, + weight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? Colors.white : EtmTokens.muted, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ─────────────────────────── Chip date ───────────────────────────────────────── + +class _DateChip extends StatelessWidget { + final DateTime date; + final VoidCallback onClear; + const _DateChip({required this.date, required this.onClear}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: onClear, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: EtmTokens.blueSoft, + borderRadius: BorderRadius.circular(99), + border: Border.all(color: EtmTokens.blue.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.calendar_today_rounded, size: 12, color: EtmTokens.blue), + const SizedBox(width: 6), + Text( + '${date.day.toString().padLeft(2, '0')}/' + '${date.month.toString().padLeft(2, '0')}/' + '${date.year}', + style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.blue), + ), + const SizedBox(width: 6), + const Icon(Icons.close_rounded, size: 12, color: EtmTokens.blue), + ], + ), + ), + ), + ); + } +} + +// ─────────────────────────── Carte graphe ────────────────────────────────────── + +class _EtmChartCard extends StatelessWidget { final String title; - final List<_LegendItem> legend; + final String subtitle; + final List legend; + final double height; + final bool loading; + final bool noData; final Widget child; - const _ChartCard({ + const _EtmChartCard({ required this.title, + required this.subtitle, required this.legend, + required this.height, + required this.loading, + required this.noData, required this.child, }); @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: AppTheme.cardWhite, - borderRadius: BorderRadius.circular(AppTheme.cornerRadius), + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 13)), - const SizedBox(height: 6), - Wrap( - spacing: 12, - runSpacing: 4, - children: legend, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: EtmTokens.sans(size: 16, weight: FontWeight.w600)), + Text(subtitle, + style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + ], + ), + ], + ), + const SizedBox(height: 10), + Wrap(spacing: 14, runSpacing: 4, children: legend), + const SizedBox(height: 14), + SizedBox( + height: height, + child: loading + ? Center(child: CircularProgressIndicator( + strokeWidth: 2, color: EtmTokens.green)) + : noData + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bar_chart_rounded, + color: EtmTokens.faint, size: 32), + const SizedBox(height: 8), + Text('Aucune donnée', + style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), + ], + ), + ) + : child, ), - const SizedBox(height: 12), - child, ], ), ); } } -class _LegendItem extends StatelessWidget { +// ─────────────────────────── Légende ─────────────────────────────────────────── + +class _LegendDot extends StatelessWidget { final Color color; final String label; final bool dashed; - const _LegendItem({ - required this.color, - required this.label, - this.dashed = false, - }); + const _LegendDot(this.color, this.label, {this.dashed = false}); @override Widget build(BuildContext context) { @@ -722,107 +803,72 @@ class _LegendItem extends StatelessWidget { Container(width: 5, height: 2, color: color), ]) : Container( - width: 12, - height: 3, + width: 14, height: 3, decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2)), - ), - const SizedBox(width: 4), - Text(label, - style: const TextStyle( - fontSize: 10, color: AppTheme.textLight)), + color: color, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 5), + Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), ], ); } } -// ── Tuiles résumé temps-réel ────────────────────────────────────────────────── +// ─────────────────────────── Météo placeholder ───────────────────────────────── -class _SummaryRow extends StatelessWidget { - final NymeaService service; - - const _SummaryRow({required this.service}); +class _ForecastPlaceholder extends StatelessWidget { + const _ForecastPlaceholder(); @override Widget build(BuildContext context) { - final d = service.energyData; - return Row( - children: [ - _Tile( - icon: Icons.wb_sunny_rounded, - color: AppTheme.solarYellow, - label: 'Production', - value: _kWh(d.dayProductionWh), - ), - const SizedBox(width: 8), - _Tile( - icon: Icons.home_rounded, - color: AppTheme.homeBlue, - label: 'Consommation', - value: _kWh(d.daySelfConsumptionWh), - ), - const SizedBox(width: 8), - _Tile( - icon: Icons.recycling_rounded, - color: AppTheme.accentTeal, - label: 'Autoconso', - value: '${d.selfConsumptionRate.toStringAsFixed(0)} %', - ), - const SizedBox(width: 8), - _Tile( - icon: Icons.euro_rounded, - color: Colors.amber, - label: 'Gains', - value: '${d.dayGains.toStringAsFixed(2)} €', - ), - ], - ); - } - - static String _kWh(double wh) => - '${(wh / 1000).toStringAsFixed(2)} kWh'; -} - -class _Tile extends StatelessWidget { - final IconData icon; - final Color color; - final String label; - final String value; - - const _Tile({ - required this.icon, - required this.color, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), - decoration: BoxDecoration( - color: AppTheme.cardWhite, - borderRadius: BorderRadius.circular(AppTheme.cornerRadius), - ), - child: Column( - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(height: 4), - Text(value, - style: TextStyle( - fontWeight: FontWeight.bold, - color: color, - fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis), - const SizedBox(height: 2), - Text(label, - style: const TextStyle( - fontSize: 9, color: AppTheme.textLight)), - ], - ), + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Météo & prévision', + style: EtmTokens.sans(size: 16, weight: FontWeight.w600)), + Text('aujourd\'hui', + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), + ], + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: EtmTokens.bg, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 30), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Prévisions non disponibles', + style: EtmTokens.sans(size: 13, weight: FontWeight.w500, + color: EtmTokens.muted)), + const SizedBox(height: 4), + Text( + 'Configurez un provider dans Tarifs & Héos ' + 'pour activer les prévisions PV et tarifaires.', + style: EtmTokens.sans(size: 11, color: EtmTokens.faint)), + ], + ), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/screens/things_screen.dart b/lib/screens/things_screen.dart index a93ee4f..7053e03 100644 --- a/lib/screens/things_screen.dart +++ b/lib/screens/things_screen.dart @@ -1,34 +1,50 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/nymea_models.dart'; import '../models/thing_category.dart'; import '../services/nymea_service.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; import '../main.dart' show DrawerMenuButton; import 'category_overview_screen.dart'; // ───────────────────────────────────────────────────────────────────────────── -// ThingsScreen — NIVEAU 1 -// Grille 2 colonnes : une tuile par catégorie présente. -// Chaque tuile affiche l'icône/nom de catégorie et un carousel des things. +// ThingsScreen — grille de catégories à hauteur intrinsèque +// +// Règle : pas de GridView avec childAspectRatio figé. +// Layout : Column de Row(Expanded × 2) → hauteur naturelle par catégorie. // ───────────────────────────────────────────────────────────────────────────── -class ThingsScreen extends StatelessWidget { +class ThingsScreen extends StatefulWidget { const ThingsScreen({super.key}); + @override + State createState() => _ThingsScreenState(); +} + +class _ThingsScreenState extends State { static const _orderedCats = [ - ThingCategory.energy, ThingCategory.solar, ThingCategory.battery, - ThingCategory.evCharger, ThingCategory.cars, ThingCategory.hvac, - ThingCategory.lighting, ThingCategory.sensors, ThingCategory.network, - ThingCategory.notifications, ThingCategory.weather, ThingCategory.media, + ThingCategory.solar, ThingCategory.battery, + ThingCategory.evCharger, ThingCategory.hvac, + ThingCategory.energy, ThingCategory.cars, + ThingCategory.lighting, ThingCategory.sensors, + ThingCategory.network, ThingCategory.weather, + ThingCategory.media, ThingCategory.notifications, ThingCategory.other, ]; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final service = context.read(); + if (!service.connected) service.startSimulation(); + }); + } + @override Widget build(BuildContext context) { final service = context.watch(); - final things = service.things.where((t) => t.id.isNotEmpty).toList(); + final things = service.things.where((t) => t.id.isNotEmpty).toList(); final classes = service.thingClasses; // Groupement par catégorie @@ -40,176 +56,262 @@ class ThingsScreen extends StatelessWidget { } final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList(); + // Statut global + final allOnline = things.isNotEmpty && + things.every((t) => _isOnline(t, _classFor(t, classes))); + final offlineCount = things.where((t) => !_isOnline(t, _classFor(t, classes))).length; + return Scaffold( - backgroundColor: AppTheme.backgroundGray, - appBar: _buildAppBar(service, things.length), - body: !service.isConnected - ? _buildPlaceholder( - icon: Icons.cloud_off_rounded, - label: 'Non connecté à nymea', - ) - : things.isEmpty && !service.thingsLoaded - ? const Center( - child: CircularProgressIndicator(color: AppTheme.accentTeal)) - : things.isEmpty - ? _buildPlaceholder( - icon: Icons.devices_other_rounded, - label: 'Aucun appareil', - ) - : GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 0.98, - ), - // Si les classes ne sont pas encore chargées → 1 tuile "Autres" - itemCount: cats.isEmpty ? 1 : cats.length, - itemBuilder: (context, i) { - final cat = cats.isEmpty ? ThingCategory.other : cats[i]; - final catThings = grouped[cat] ?? things; - final info = categoryInfoMap[cat]!; - return _CategoryTile( - info: info, - things: catThings, - thingClasses: classes, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CategoryOverviewScreen( - info: info, - things: catThings, - thingClasses: classes, - ), - ), - ), - ); - }, + backgroundColor: EtmTokens.bg, + body: CustomScrollView( + slivers: [ + // AppBar + SliverAppBar( + floating: true, + backgroundColor: EtmTokens.bg, + elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 64, + title: Row( + children: [ + Text('Things', + style: EtmTokens.sans(size: 20, weight: FontWeight.w600)), + const SizedBox(width: 8), + if (things.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: EtmTokens.greenSoft, + borderRadius: BorderRadius.circular(8), ), - ); - } - - AppBar _buildAppBar(NymeaService service, int count) { - return AppBar( - backgroundColor: AppTheme.backgroundGray, - elevation: 0, - leading: const DrawerMenuButton(), - leadingWidth: 56, - title: Row(children: [ - const Text('Things', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 20)), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.accentTeal.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), + child: Text('${things.length}', + style: EtmTokens.mono(size: 12, weight: FontWeight.w700, + color: EtmTokens.greenDark)), + ), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: const Icon(Icons.refresh_rounded, + color: EtmTokens.muted, size: 20), + onPressed: service.refresh, + ), + ), + ], ), - child: Text('$count', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppTheme.accentTeal)), - ), - ]), - actions: [ - IconButton( - icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark), - onPressed: () => service.refresh(), - ), - ], + + // Body + SliverPadding( + padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), + sliver: SliverList( + delegate: SliverChildListDelegate([ + + // Bandeau statut global + if (service.isConnected || service.isSimulation) ...[ + _StatusBanner( + allOnline: allOnline, + offlineCount: offlineCount, + simulation: service.isSimulation, + thingCount: things.length, + ), + const SizedBox(height: 16), + ], + + // État de chargement + if (!service.isConnected && !service.isSimulation) + _Placeholder( + icon: Icons.cloud_off_rounded, + label: 'Non connecté à nymea', + ) + else if (things.isEmpty && !service.thingsLoaded) + const Padding( + padding: EdgeInsets.only(top: 60), + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, color: EtmTokens.green), + ), + ) + else if (things.isEmpty) + _Placeholder( + icon: Icons.devices_other_rounded, + label: 'Aucun appareil configuré', + ) + else + _CategoryGrid( + cats: cats, + grouped: grouped, + classes: classes, + ), + ]), + ), + ), + ], + ), ); } - static Widget _buildPlaceholder( - {required IconData icon, required String label}) => - Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 64, color: Colors.grey[400]), - const SizedBox(height: 12), - Text(label, - style: - const TextStyle(color: AppTheme.textLight, fontSize: 16)), - ]), - ); - - static NymeaThingClass? _classFor( - NymeaThing t, List classes) { - try { - return classes.firstWhere((c) => c.id == t.thingClassId); - } catch (_) { - return null; - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// _CategoryTile — tuile d'une catégorie -// -// ┌────────────────────────────┐ -// │ [icône catégorie] │ ← centrée, colorée -// │ NOM CATÉGORIE (CAPS) │ -// │────────────────────────────│ ← séparateur couleur catégorie -// │ ● Thing name valeur │ ← carousel auto (3 s) si >1 thing -// └────────────────────────────┘ -// ───────────────────────────────────────────────────────────────────────────── - -class _CategoryTile extends StatefulWidget { - final ThingCategoryInfo info; - final List things; - final List thingClasses; - final VoidCallback onTap; - - const _CategoryTile({ - required this.info, - required this.things, - required this.thingClasses, - required this.onTap, - }); - - @override - State<_CategoryTile> createState() => _CategoryTileState(); -} - -class _CategoryTileState extends State<_CategoryTile> { - int _idx = 0; - Timer? _timer; - - @override - void initState() { - super.initState(); - if (widget.things.length > 1) { - _timer = Timer.periodic(const Duration(seconds: 3), (_) { - if (mounted) { - setState( - () => _idx = (_idx + 1) % widget.things.length); - } - }); - } - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - NymeaThingClass? _cls(NymeaThing t) { - try { - return widget.thingClasses.firstWhere((c) => c.id == t.thingClassId); - } catch (_) { - return null; - } + NymeaThingClass? _classFor(NymeaThing t, List cls) { + try { return cls.firstWhere((c) => c.id == t.thingClassId); } + catch (_) { return null; } } bool _isOnline(NymeaThing t, NymeaThingClass? cls) { - final ct = - cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected'); + final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected'); + if (ct != null && ct.isNotEmpty) { + final v = t.stateValue(ct.first.id); + return v == true || v == 'true'; + } + return t.isSetupComplete; + } +} + +// ─────────────────────────── Bandeau statut ──────────────────────────────────── + +class _StatusBanner extends StatelessWidget { + final bool allOnline; + final int offlineCount; + final bool simulation; + final int thingCount; + + const _StatusBanner({ + required this.allOnline, + required this.offlineCount, + required this.simulation, + required this.thingCount, + }); + + @override + Widget build(BuildContext context) { + final Color dotColor; + final String text; + + if (simulation) { + dotColor = EtmTokens.amber; + text = 'Mode simulation · $thingCount appareil(s) simulé(s)'; + } else if (allOnline) { + dotColor = EtmTokens.green; + text = 'Tous les appareils connectés · dernière synchro à l\'instant'; + } else if (offlineCount > 0) { + dotColor = EtmTokens.orange; + text = '$offlineCount appareil(s) hors ligne'; + } else { + dotColor = EtmTokens.faint; + text = 'Aucun appareil connecté'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radius), + boxShadow: EtmTokens.cardShadow, + ), + child: Row( + children: [ + Container( + width: 8, height: 8, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), + ), + ], + ), + ); + } +} + +// ─────────────────────────── Grille intrinsèque ──────────────────────────────── + +class _CategoryGrid extends StatelessWidget { + final List cats; + final Map> grouped; + final List classes; + + const _CategoryGrid({ + required this.cats, + required this.grouped, + required this.classes, + }); + + @override + Widget build(BuildContext context) { + final rows = []; + for (int i = 0; i < cats.length; i += 2) { + rows.add( + Row( + crossAxisAlignment: CrossAxisAlignment.start, // hauteur intrinsèque + children: [ + Expanded( + child: _CategoryCard( + cat: cats[i], + things: grouped[cats[i]] ?? [], + classes: classes, + onTap: () => _openCategory(context, cats[i], grouped[cats[i]] ?? [], classes), + ), + ), + const SizedBox(width: 14), + Expanded( + child: i + 1 < cats.length + ? _CategoryCard( + cat: cats[i + 1], + things: grouped[cats[i + 1]] ?? [], + classes: classes, + onTap: () => _openCategory( + context, cats[i + 1], grouped[cats[i + 1]] ?? [], classes), + ) + : const SizedBox(), + ), + ], + ), + ); + if (i + 2 < cats.length) rows.add(const SizedBox(height: 14)); + } + return Column(children: rows); + } + + void _openCategory(BuildContext context, ThingCategory cat, + List things, List classes) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CategoryOverviewScreen( + info: categoryInfoMap[cat]!, + things: things, + thingClasses: classes, + ), + ), + ); + } +} + +// ─────────────────────────── Carte catégorie ─────────────────────────────────── + +class _CategoryCard extends StatelessWidget { + final ThingCategory cat; + final List things; + final List classes; + final VoidCallback onTap; + + const _CategoryCard({ + required this.cat, + required this.things, + required this.classes, + required this.onTap, + }); + + NymeaThingClass? _classFor(NymeaThing t) { + try { return classes.firstWhere((c) => c.id == t.thingClassId); } + catch (_) { return null; } + } + + bool _isOnline(NymeaThing t) { + final cls = _classFor(t); + final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected'); if (ct != null && ct.isNotEmpty) { final v = t.stateValue(ct.first.id); return v == true || v == 'true'; @@ -217,92 +319,185 @@ class _CategoryTileState extends State<_CategoryTile> { return t.isSetupComplete; } - String? _primaryValue(NymeaThing t, NymeaThingClass? cls) { - final p = cls?.primaryStateType; + String? _value(NymeaThing t) { + final cls = _classFor(t); + final p = cls?.primaryStateType; if (p == null) return null; return p.formatValue(t.stateValue(p.id)); } @override Widget build(BuildContext context) { - // Clamp _idx au cas où la liste shrink entre deux ticks du timer - final safeIdx = _idx.clamp(0, widget.things.length - 1); - final current = widget.things[safeIdx]; - final cls = _cls(current); - final online = _isOnline(current, cls); - final value = _primaryValue(current, cls); - final info = widget.info; + final info = categoryInfoMap[cat]!; + final count = things.length; - return Material( - color: AppTheme.cardWhite, - borderRadius: BorderRadius.circular(AppTheme.cornerRadius), - child: InkWell( - onTap: widget.onTap, - borderRadius: BorderRadius.circular(AppTheme.cornerRadius), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ── Haut : icône + nom catégorie ─────────────────────────────── - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: info.color.withValues(alpha: 0.13), - borderRadius: BorderRadius.circular(14), - ), - child: Icon(info.icon, color: info.color, size: 28), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text( - info.label.toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: AppTheme.textDark.withValues(alpha: 0.75), - letterSpacing: 0.7, + // Container en dehors de Material pour que l'ombre soit visible + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radius), + boxShadow: EtmTokens.cardShadow, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(EtmTokens.radius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(EtmTokens.radius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + // ── En-tête ──────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + child: Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: info.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: Icon(info.icon, color: info.color, size: 20), ), - ), - ], - ), - ), - - // ── Séparateur coloré ─────────────────────────────────────────── - Container( - height: 1, - color: info.color.withValues(alpha: 0.25), - ), - - // ── Bas : carousel des things ─────────────────────────────────── - SizedBox( - height: 52, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - transitionBuilder: (child, anim) => - FadeTransition(opacity: anim, child: child), - child: _CarouselRow( - key: ValueKey(_idx), - name: current.name, - value: value, - count: widget.things.length, - index: _idx, - isOnline: online, - color: info.color, - ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.label.toUpperCase(), + style: EtmTokens.sans( + size: 9, + weight: FontWeight.w700, + color: EtmTokens.navy.withValues(alpha: 0.6), + letterSpacing: 0.6, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '$count appareil${count > 1 ? 's' : ''}', + style: EtmTokens.sans(size: 11, color: EtmTokens.muted), + ), + ], + ), + ), + ], ), ), + + // ── Séparateur coloré ────────────────────────────────────────── + Container(height: 1, color: info.color.withValues(alpha: 0.20)), + + // ── Liste des things ─────────────────────────────────────────── + ...things.map((t) => _ThingRow( + thing: t, + value: _value(t), + isOnline: _isOnline(t), + color: info.color, + isLast: t == things.last, + )), + ], + ), + ), + ), + ); // closes InkWell + } // closes build +} // closes _CategoryCard + +// ─────────────────────────── Ligne thing ─────────────────────────────────────── + +class _ThingRow extends StatelessWidget { + final NymeaThing thing; + final String? value; + final bool isOnline; + final Color color; + final bool isLast; + + const _ThingRow({ + required this.thing, + required this.value, + required this.isOnline, + required this.color, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 9, 14, 9), + decoration: BoxDecoration( + border: isLast + ? null + : Border(bottom: BorderSide(color: EtmTokens.line)), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(EtmTokens.radius)) + : null, + ), + child: Row( + children: [ + Container( + width: 7, height: 7, + decoration: BoxDecoration( + color: isOnline ? color : EtmTokens.faint, + shape: BoxShape.circle, ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + thing.name, + style: EtmTokens.sans(size: 12, weight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + if (value != null) + Text( + value!, + style: EtmTokens.mono( + size: 12, + weight: FontWeight.w700, + color: isOnline ? color : EtmTokens.muted, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + else + Text( + isOnline ? 'OK' : 'Veille', + style: EtmTokens.sans( + size: 12, + color: isOnline ? color : EtmTokens.faint, + ), + ), + ], + ), + ); + } +} + +// ─────────────────────────── Placeholder ─────────────────────────────────────── + +class _Placeholder extends StatelessWidget { + final IconData icon; + final String label; + const _Placeholder({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 80), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56, color: EtmTokens.faint), + const SizedBox(height: 14), + Text(label, style: EtmTokens.sans(size: 15, color: EtmTokens.muted)), ], ), ), @@ -310,75 +505,7 @@ class _CategoryTileState extends State<_CategoryTile> { } } -// ── Ligne carousel ──────────────────────────────────────────────────────────── - -class _CarouselRow extends StatelessWidget { - final String name; - final String? value; - final int count; - final int index; - final bool isOnline; - final Color color; - - const _CarouselRow({ - super.key, - required this.name, - required this.value, - required this.count, - required this.index, - required this.isOnline, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - // Pastille statut - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: isOnline ? color : Colors.grey[400], - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - // Nom du thing - Expanded( - child: Text( - name, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppTheme.textDark), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 4), - // Valeur primaire — Flexible pour éviter l'overflow - Flexible( - fit: FlexFit.loose, - child: Text( - value ?? '—', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: isOnline ? color : AppTheme.textLight, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ThingCard — conservé pour compatibilité externe -// ───────────────────────────────────────────────────────────────────────────── +// ─────────────────────────── ThingCard (compat externe) ──────────────────────── class ThingCard extends StatelessWidget { final NymeaThing thing; @@ -400,9 +527,10 @@ class ThingCard extends StatelessWidget { final value = primary?.formatValue(thing.stateValue(primary.id)); return ListTile( leading: Icon(categoryInfo.icon, color: categoryInfo.color), - title: Text(thing.name), - trailing: - value != null ? Text(value, style: TextStyle(color: categoryInfo.color)) : null, + title: Text(thing.name, style: EtmTokens.sans(size: 14)), + trailing: value != null + ? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.color)) + : null, onTap: onTap, ); } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index dde9b32..c2c5b71 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; class AppTheme { // ── Couleurs historiques (dashboard, widgets existants) ────────────────────── @@ -50,19 +51,19 @@ class AppTheme { // ── Thème Material3 ─────────────────────────────────────────────────────────── static ThemeData get theme => ThemeData( colorScheme: ColorScheme.fromSeed( - seedColor: accentTeal, - surface: backgroundGray, + seedColor: const Color(0xFF1DB86A), + surface: const Color(0xFFF3F6F8), ), - scaffoldBackgroundColor: backgroundGray, + scaffoldBackgroundColor: const Color(0xFFF3F6F8), cardTheme: CardThemeData( color: cardWhite, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(22), ), margin: EdgeInsets.zero, ), - fontFamily: 'Roboto', + textTheme: GoogleFonts.ibmPlexSansTextTheme(ThemeData.light().textTheme), useMaterial3: true, ); } diff --git a/lib/theme/etm_tokens.dart b/lib/theme/etm_tokens.dart new file mode 100644 index 0000000..d8209fe --- /dev/null +++ b/lib/theme/etm_tokens.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Tokens de marque ETM PowerSync. +/// Source de vérité unique pour les couleurs et la typographie. +class EtmTokens { + EtmTokens._(); + + // ---- Couleurs de marque ---- + static const Color navy = Color(0xFF0D2B3B); + static const Color navy2 = Color(0xFF10384C); + static const Color blue = Color(0xFF31A3DD); + static const Color amber = Color(0xFFFEC113); + static const Color green = Color(0xFF1DB86A); + static const Color greenDark = Color(0xFF159C58); + static const Color orange = Color(0xFFE8923A); + static const Color danger = Color(0xFFE8423F); + + // ---- Neutres ---- + static const Color ink = navy; + static const Color muted = Color(0xFF6B7D88); + static const Color faint = Color(0xFF9AABB4); + static const Color line = Color(0xFFE6ECF0); + static const Color bg = Color(0xFFF3F6F8); + static const Color card = Colors.white; + + // ---- Soft ---- + static const Color greenSoft = Color(0xFFE7F8EF); + static const Color blueSoft = Color(0xFFE9F5FB); + static const Color amberSoft = Color(0xFFFFF6DD); + + // ---- Rayons ---- + static const double radius = 16; + static const double radiusLg = 22; + + // ---- Typographie ---- + static TextStyle sans({ + double size = 14, + FontWeight weight = FontWeight.w400, + Color color = ink, + double? height, + double? letterSpacing, + }) => + GoogleFonts.ibmPlexSans( + fontSize: size, + fontWeight: weight, + color: color, + height: height, + letterSpacing: letterSpacing, + ); + + /// IBM Plex Mono pour TOUS les chiffres (alignement tabulaire). + static TextStyle mono({ + double size = 14, + FontWeight weight = FontWeight.w600, + Color color = ink, + }) => + GoogleFonts.ibmPlexMono( + fontSize: size, + fontWeight: weight, + color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ); + + static TextStyle sectionLabel() => sans( + size: 12, + weight: FontWeight.w600, + color: faint, + letterSpacing: 1.2, + ); + + /// Décoration shadow standard pour les cartes. + static List get cardShadow => [ + BoxShadow( + color: navy.withValues(alpha: 0.04), + blurRadius: 2, + offset: const Offset(0, 1), + ), + BoxShadow( + color: navy.withValues(alpha: 0.06), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ]; +} diff --git a/lib/widgets/energy_flow_widget.dart b/lib/widgets/energy_flow_widget.dart index ec3ffc8..f09bf83 100644 --- a/lib/widgets/energy_flow_widget.dart +++ b/lib/widgets/energy_flow_widget.dart @@ -1,288 +1,438 @@ +import 'dart:math' as math; import 'package:flutter/material.dart'; import '../models/energy_data.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; -class EnergyFlowWidget extends StatelessWidget { +/// Diagramme de flux d'énergie animé — réutilisable Dashboard + Énergie. +/// +/// Layout 4 nœuds : Solaire (haut centre), Maison (centre), Batterie (bas gauche), +/// Réseau (bas droite). Les lignes de flux sont animées via CustomPainter. +class EnergyFlowWidget extends StatefulWidget { final EnergyData data; - const EnergyFlowWidget({super.key, required this.data}); + @override + State createState() => _EnergyFlowWidgetState(); +} + +class _EnergyFlowWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Expanded( - child: Text( - 'Données en temps réel', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Row( - children: [ - Text( - '${data.temperature.toStringAsFixed(0)}°C', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppTheme.textDark, - ), - ), - const SizedBox(width: 4), - const Icon(Icons.cloud, color: Colors.blueGrey, size: 22), - ], - ), - ], - ), - const SizedBox(height: 20), - - // Energy nodes - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _EnergyNode( - icon: Icons.wb_sunny_rounded, - color: AppTheme.solarYellow, - power: data.pvPower, - label: 'Solaire', - ), - _EnergyNode( - icon: Icons.home_rounded, - color: AppTheme.homeBlue, - power: data.homePower, - label: 'Maison', - ), - _EnergyNode( - icon: Icons.battery_charging_full_rounded, - color: AppTheme.batteryGreen, - power: data.batteryPower.abs(), - label: 'Batterie', - badge: '${data.batterySOC.toStringAsFixed(0)}%', - ), - _EnergyNode( - icon: Icons.electrical_services_rounded, - color: AppTheme.gridGray, - power: data.gridPower.abs(), - label: data.gridPower >= 0 ? 'Réseau ↓' : 'Réseau ↑', - ), - ], - ), - const SizedBox(height: 16), - - // Flow diagram - SizedBox( - height: 80, - child: CustomPaint( - painter: _FlowPainter(data: data), - size: Size.infinite, + final data = widget.data; + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Flux d\'énergie en temps réel', + style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), + Row( + children: [ + Text('${data.temperature.toStringAsFixed(0)}°C', + style: EtmTokens.sans(size: 14, color: EtmTokens.muted)), + const SizedBox(width: 4), + const Icon(Icons.wb_cloudy_outlined, color: EtmTokens.faint, size: 20), + ], ), + ], + ), + const SizedBox(height: 16), + + // Flow diagram + SizedBox( + height: 240, + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + final nodeR = w * 0.13; // rayon nœud ≈52px sur 400px + final nodes = _NodePositions(w: w, h: h, r: nodeR); + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CustomPaint( + painter: _FlowPainter( + nodes: nodes, + data: data, + t: _ctrl.value, + ), + ), + ), + _sunNode(center: nodes.sun, r: nodeR, data: data), + _homeNode(center: nodes.home, r: nodeR * 1.08, data: data), + _battNode(center: nodes.batt, r: nodeR, data: data), + _gridNode(center: nodes.grid, r: nodeR, data: data), + ], + ), + ); + }, ), - ], - ), + ), + + // Footer mode optimal + const SizedBox(height: 6), + _OptimalBanner(), + ], ), ); } } -class _EnergyNode extends StatelessWidget { - final IconData icon; - final Color color; - final double power; - final String label; - final String? badge; +// ─────────────────────────── Positions ───────────────────────────────────────── - const _EnergyNode({ +class _NodePositions { + final double w, h, r; + late final Offset sun, home, batt, grid; + + _NodePositions({required this.w, required this.h, required this.r}) { + sun = Offset(w * 0.5, h * 0.19); + home = Offset(w * 0.5, h * 0.73); + batt = Offset(w * 0.14, h * 0.73); + grid = Offset(w * 0.86, h * 0.73); + } +} + +// ─────────────────────────── Painter ─────────────────────────────────────────── + +class _FlowPainter extends CustomPainter { + final _NodePositions nodes; + final EnergyData data; + final double t; + + const _FlowPainter({required this.nodes, required this.data, required this.t}); + + @override + void paint(Canvas canvas, Size size) { + final r = nodes.r; + + // Sun → Home + _line( + canvas, + from: Offset(nodes.sun.dx, nodes.sun.dy + r), + to: Offset(nodes.home.dx, nodes.home.dy - r * 1.08), + color: data.pvPower > 0 ? EtmTokens.amber : EtmTokens.line, + animated: data.pvPower > 0, + arrowDown: true, + ); + + // Home ↔ Battery + final battFlow = data.batteryPower; + if (battFlow > 0) { + // Charging: Home → Battery + _line(canvas, + from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), + to: Offset(nodes.batt.dx + r, nodes.batt.dy), + color: EtmTokens.green, animated: true, arrowLeft: true); + } else if (battFlow < 0) { + // Discharging: Battery → Home + _line(canvas, + from: Offset(nodes.batt.dx + r, nodes.batt.dy), + to: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), + color: EtmTokens.amber, animated: true, arrowLeft: false); + } else { + _line(canvas, + from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), + to: Offset(nodes.batt.dx + r, nodes.batt.dy), + color: EtmTokens.line, animated: false); + } + + // Réseau ↔ Maison (gridPower > 0 = soutirage = Grid → Home) + final gridFlow = data.gridPower; + final homeRight = Offset(nodes.home.dx + r * 1.08, nodes.home.dy); + final gridLeft = Offset(nodes.grid.dx - r, nodes.grid.dy); + if (gridFlow > 20) { + // Soutirage : Grid → Home (amber) + _line(canvas, from: gridLeft, to: homeRight, + color: EtmTokens.amber, animated: true); + } else if (gridFlow < -20) { + // Injection : Home → Grid (blue) + _line(canvas, from: homeRight, to: gridLeft, + color: EtmTokens.blue, animated: true); + } else { + _line(canvas, from: homeRight, to: gridLeft, + color: EtmTokens.line, animated: false); + } + } + + void _line( + Canvas canvas, { + required Offset from, + required Offset to, + required Color color, + required bool animated, + bool arrowDown = false, + bool arrowLeft = false, + }) { + final dx = to.dx - from.dx; + final dy = to.dy - from.dy; + final len = math.sqrt(dx * dx + dy * dy); + if (len < 1) return; + + final paint = Paint() + ..color = color + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + const dash = 4.0; + const gap = 7.0; + const period = dash + gap; + + final offset = animated ? (t * period) % period : 0.0; + double dist = -offset; + + while (dist < len) { + final d0 = dist.clamp(0.0, len); + final d1 = (dist + dash).clamp(0.0, len); + if (d1 > d0) { + canvas.drawLine( + Offset(from.dx + dx / len * d0, from.dy + dy / len * d0), + Offset(from.dx + dx / len * d1, from.dy + dy / len * d1), + paint, + ); + } + dist += period; + } + + // Arrow head at destination + if (animated) { + _arrow(canvas, from, to, color); + } + } + + void _arrow(Canvas canvas, Offset from, Offset to, Color color) { + final dx = to.dx - from.dx; + final dy = to.dy - from.dy; + final len = math.sqrt(dx * dx + dy * dy); + if (len < 1) return; + final nx = dx / len; + final ny = dy / len; + const size = 7.0; + final tip = to; + final p1 = Offset(tip.dx - nx * size + ny * size * 0.5, + tip.dy - ny * size - nx * size * 0.5); + final p2 = Offset(tip.dx - nx * size - ny * size * 0.5, + tip.dy - ny * size + nx * size * 0.5); + final path = Path()..moveTo(tip.dx, tip.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close(); + canvas.drawPath(path, Paint()..color = color..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(_FlowPainter old) => + old.t != t || old.data.pvPower != data.pvPower || + old.data.batteryPower != data.batteryPower || + old.data.gridPower != data.gridPower; +} + +// ─────────────────────────── Nœuds ───────────────────────────────────────────── + +Widget _sunNode({required Offset center, required double r, required EnergyData data}) { + final kw = (data.pvPower / 1000); + return _Node( + center: center, + r: r, + borderColor: const Color(0xFFFDE9A8), + icon: Icons.wb_sunny_rounded, + iconColor: EtmTokens.amber, + label: 'Solaire', + value: '${kw.toStringAsFixed(2)} kW', + valueColor: EtmTokens.amber, + ); +} + +Widget _homeNode({required Offset center, required double r, required EnergyData data}) { + return _Node( + center: center, + r: r, + borderColor: const Color(0xFFBFE2F3), + ringColor: const Color(0xFFEAF6FC), + icon: Icons.home_rounded, + iconColor: EtmTokens.blue, + label: 'Maison', + value: '${data.homePower.toStringAsFixed(0)} W', + valueColor: EtmTokens.blue, + ); +} + +Widget _battNode({required Offset center, required double r, required EnergyData data}) { + final sign = data.batteryPower > 0 ? '+ ' : data.batteryPower < 0 ? '− ' : ''; + return _Node( + center: center, + r: r, + borderColor: const Color(0xFFC4EED7), + icon: Icons.battery_charging_full_rounded, + iconColor: EtmTokens.green, + label: 'Batterie', + value: '${data.batterySOC.toStringAsFixed(0)}%', + valueColor: EtmTokens.green, + sub: '$sign${data.batteryPower.abs().toStringAsFixed(0)} W', + subColor: EtmTokens.green, + ); +} + +Widget _gridNode({required Offset center, required double r, required EnergyData data}) { + return _Node( + center: center, + r: r, + icon: Icons.electrical_services_rounded, + iconColor: EtmTokens.faint, + label: 'Réseau', + value: '${data.gridPower.abs().toStringAsFixed(0)} W', + valueColor: EtmTokens.muted, + ); +} + +class _Node extends StatelessWidget { + final Offset center; + final double r; + final Color? borderColor; + final Color? ringColor; + final IconData icon; + final Color iconColor; + final String label; + final String value; + final Color valueColor; + final String? sub; + final Color? subColor; + + const _Node({ + required this.center, + required this.r, + this.borderColor, + this.ringColor, required this.icon, - required this.color, - required this.power, + required this.iconColor, required this.label, - this.badge, + required this.value, + required this.valueColor, + this.sub, + this.subColor, }); @override Widget build(BuildContext context) { - return Column( - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, + final diameter = r * 2; + return Positioned( + left: center.dx - r, + top: center.dy - r, + width: diameter, + height: diameter + 32, // extra for label below + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: borderColor ?? EtmTokens.line, + width: 1.5, ), - child: Icon(icon, color: Colors.white, size: 24), + boxShadow: ringColor != null + ? [ + BoxShadow(color: ringColor!, blurRadius: 0, spreadRadius: 5), + BoxShadow( + color: EtmTokens.navy.withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, 4)), + ] + : [ + BoxShadow( + color: EtmTokens.navy.withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, 4)), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: iconColor, size: r * 0.56), + const SizedBox(height: 2), + Text(value, style: EtmTokens.mono(size: r * 0.32, color: valueColor)), + if (sub != null) + Text(sub!, style: EtmTokens.mono(size: r * 0.26, color: subColor ?? EtmTokens.muted)), + ], ), - if (badge != null) - Positioned( - bottom: -4, - left: 0, - right: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color, width: 1.5), - ), - child: Text( - badge!, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - _formatPower(power), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textDark, ), - ), - Text( - _powerUnit(power), - style: const TextStyle( - fontSize: 12, - color: AppTheme.textLight, + const SizedBox(height: 4), + Text( + label, + style: EtmTokens.sans(size: 11, color: EtmTokens.muted), + textAlign: TextAlign.center, ), - ), - Text( - label, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textLight, - ), - ), - ], + ], + ), ); } - - String _formatPower(double w) { - if (w >= 1000) return (w / 1000).toStringAsFixed(1); - return w.toStringAsFixed(0); - } - - String _powerUnit(double w) => w >= 1000 ? 'kW' : 'W'; } -class _FlowPainter extends CustomPainter { - final EnergyData data; +// ─────────────────────────── Bannière ────────────────────────────────────────── - _FlowPainter({required this.data}); +class _OptimalBanner extends StatelessWidget { + const _OptimalBanner(); @override - void paint(Canvas canvas, Size size) { - // Draw animated flow lines between nodes - final centerY = size.height * 0.4; - final nodePositions = [ - size.width * 0.125, // PV - size.width * 0.375, // Home - size.width * 0.625, // Battery - size.width * 0.875, // Grid - ]; - - final linePaint = Paint() - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - - // PV → Home (si PV produit) - if (data.pvPower > 0) { - _drawArrowLine(canvas, Offset(nodePositions[0], centerY), - Offset(nodePositions[1], centerY), AppTheme.solarYellow, linePaint); - } - - // Batterie en charge : source = PV si disponible, sinon Réseau → Batterie - if (data.batteryPower > 0) { - if (data.pvPower > 0) { - // PV → Battery (solaire charge la batterie) - _drawArrowLine(canvas, Offset(nodePositions[0], centerY), - Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint); - } else { - // Grid → Battery (réseau charge la batterie) - _drawArrowLine(canvas, Offset(nodePositions[3], centerY), - Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint); - } - } - - // Batterie en décharge → Home - if (data.batteryPower < 0) { - _drawArrowLine(canvas, Offset(nodePositions[2], centerY), - Offset(nodePositions[1], centerY), AppTheme.batteryGreen, linePaint); - } - - // Réseau → Home (si import) - if (data.gridPower > 0) { - _drawArrowLine(canvas, Offset(nodePositions[3], centerY), - Offset(nodePositions[1], centerY), Colors.orange, linePaint); - } else if (data.gridPower < 0) { - // PV → Réseau (export / injection réseau) - _drawArrowLine(canvas, Offset(nodePositions[0], centerY), - Offset(nodePositions[3], centerY), AppTheme.solarYellow, linePaint); - } - - // Draw node dots - final dotPaint = Paint()..style = PaintingStyle.fill; - for (int i = 0; i < nodePositions.length; i++) { - final colors = [ - AppTheme.solarYellow, - AppTheme.homeBlue, - AppTheme.batteryGreen, - AppTheme.gridGray, - ]; - dotPaint.color = colors[i]; - canvas.drawCircle(Offset(nodePositions[i], centerY), 6, dotPaint); - } + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: EtmTokens.greenSoft, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Container( + width: 32, height: 32, + decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle), + child: const Icon(Icons.check, color: Colors.white, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Mode optimal', + style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.greenDark)), + Text('Héos optimise votre consommation', + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), + ], + ), + ), + const Icon(Icons.chevron_right, color: EtmTokens.faint, size: 20), + ], + ), + ); } - - void _drawArrowLine(Canvas canvas, Offset start, Offset end, Color color, - Paint paint) { - paint.color = color.withValues(alpha:0.7); - - final path = Path() - ..moveTo(start.dx, start.dy) - ..lineTo(end.dx, end.dy); - canvas.drawPath(path, paint); - - // Arrow head - final arrowPaint = Paint() - ..color = color - ..style = PaintingStyle.fill; - - final dx = end.dx - start.dx; - final arrowSign = dx > 0 ? 1 : -1; - final arrowTip = Offset(end.dx - arrowSign * 8, end.dy); - - final arrowPath = Path() - ..moveTo(arrowTip.dx + arrowSign * 8, arrowTip.dy) - ..lineTo(arrowTip.dx, arrowTip.dy - 5) - ..lineTo(arrowTip.dx, arrowTip.dy + 5) - ..close(); - canvas.drawPath(arrowPath, arrowPaint); - } - - @override - bool shouldRepaint(_FlowPainter oldDelegate) => true; } diff --git a/lib/widgets/ev_charging_card.dart b/lib/widgets/ev_charging_card.dart index 3072eb8..1b200a2 100644 --- a/lib/widgets/ev_charging_card.dart +++ b/lib/widgets/ev_charging_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../models/energy_data.dart'; import '../services/nymea_service.dart'; -import '../theme/app_theme.dart'; +import '../theme/etm_tokens.dart'; class EVChargingCard extends StatefulWidget { final EnergyData data; @@ -92,60 +92,108 @@ class _EVChargingCardState extends State { final data = widget.data; final showDeadlineOption = mode != ChargingMode.boost; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( + return Container( + decoration: BoxDecoration( + color: EtmTokens.card, + borderRadius: BorderRadius.circular(EtmTokens.radiusLg), + boxShadow: EtmTokens.cardShadow, + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 34, height: 34, + decoration: BoxDecoration( + color: EtmTokens.navy.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.ev_station_rounded, color: EtmTokens.navy, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Recharge du véhicule', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - ), - ), - Text( - _statusLabel(mode), - style: const TextStyle( - fontSize: 13, - color: AppTheme.textLight, - ), - ), + Text('Borne de recharge (EVSE)', + style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), + Text(_statusLabel(mode), + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), ], ), - IconButton( - icon: const Icon(Icons.settings_outlined), - onPressed: () => _showSettings(context), - color: AppTheme.textLight, - ), - ], - ), - const SizedBox(height: 16), + ), + GestureDetector( + onTap: () => _showSettings(context), + child: const Icon(Icons.tune, color: EtmTokens.faint, size: 20), + ), + ], + ), + const SizedBox(height: 14), - // Mode buttons — 3 buttons: PV / Min+PV / Boost + // Status badge + power + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: EtmTokens.greenSoft, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 6, height: 6, + decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle)), + const SizedBox(width: 5), + Text('En charge', + style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.greenDark)), + ], + ), + ), + const SizedBox(height: 8), + Text( + '${(data.chargingPower * 1000).toStringAsFixed(0)}', + style: EtmTokens.mono(size: 38, weight: FontWeight.w700), + ), + Text('W Puissance actuelle', + style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), + ], + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('${data.solarSourcePercent.toStringAsFixed(0)}%', + style: EtmTokens.mono(size: 22, color: EtmTokens.green)), + Text('solaire', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + ], + ), + ], + ), + const SizedBox(height: 16), + + // Mode buttons — 3 boutons : PV / Min+PV / Boost Row( children: [ _ModeButton( label: 'PV', icon: Icons.wb_sunny_rounded, - color: AppTheme.pvGreen, + color: EtmTokens.amber, isSelected: mode == ChargingMode.pv, onTap: () => _selectMode(ChargingMode.pv), ), const SizedBox(width: 8), _ModeButton( - label: 'Min + PV', + label: 'Min+PV', icon: Icons.bolt, - color: AppTheme.minPvBlue, + color: EtmTokens.blue, isSelected: mode == ChargingMode.minPv, onTap: () => _selectMode(ChargingMode.minPv), ), @@ -153,14 +201,14 @@ class _EVChargingCardState extends State { _ModeButton( label: 'Boost', icon: Icons.rocket_launch_rounded, - color: AppTheme.boostRed, + color: EtmTokens.green, isSelected: mode == ChargingMode.boost, onTap: () => _selectMode(ChargingMode.boost), ), ], ), - // Deadline option — visible for PV and Min+PV only + // Deadline option — visible pour PV et Min+PV if (showDeadlineOption) ...[ const SizedBox(height: 8), _DeadlineToggleRow( @@ -181,38 +229,13 @@ class _EVChargingCardState extends State { ], ], - const SizedBox(height: 16), + const SizedBox(height: 14), - // Stats - _InfoRow( - label: 'Puissance borne', - value: '${data.chargingPower.toStringAsFixed(1)} kW', - ), - const SizedBox(height: 6), - _InfoRow( - label: 'Source', - value: '${data.solarSourcePercent.toStringAsFixed(0)}% solaire', - valueColor: AppTheme.pvGreen, - ), - const SizedBox(height: 6), - _InfoRow( - label: 'Limite réseau', - value: data.gridLimitOk ? 'OK ✓' : 'Dépassée !', - valueColor: data.gridLimitOk ? AppTheme.primaryGreen : AppTheme.boostRed, - ), - - // Charging indicator - const SizedBox(height: 12), - if (mode != ChargingMode.pv || data.pvPower > 0) - _ChargingIndicator( - mode: mode, - pvPower: data.pvPower, - chargingPower: data.chargingPower, - ), + // SOC progress + _SocProgress(targetSoc: _targetSoc, currentSoc: 62), ], ), - ), - ); + ); } void _showSettings(BuildContext context) { @@ -239,24 +262,19 @@ class _DeadlineToggleRow extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.flag_outlined, - size: 16, - color: enabled ? AppTheme.accentTeal : AppTheme.textLight), + Icon(Icons.flag_outlined, size: 16, + color: enabled ? EtmTokens.blue : EtmTokens.faint), const SizedBox(width: 6), - Text( - 'Cible', - style: TextStyle( - fontSize: 13, - color: enabled ? AppTheme.accentTeal : AppTheme.textLight, - fontWeight: - enabled ? FontWeight.w600 : FontWeight.normal), - ), + Text('Cible deadline', + style: EtmTokens.sans(size: 13, + color: enabled ? EtmTokens.blue : EtmTokens.muted, + weight: enabled ? FontWeight.w600 : FontWeight.w400)), ], ), Switch( value: enabled, onChanged: onChanged, - activeColor: AppTheme.accentTeal, + activeThumbColor: EtmTokens.blue, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ], @@ -283,22 +301,19 @@ class _DeadlineParamsRow extends StatelessWidget { '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}'; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: AppTheme.accentTeal.withValues(alpha: 0.07), - borderRadius: BorderRadius.circular(10), + color: EtmTokens.blueSoft, + borderRadius: BorderRadius.circular(12), ), child: Column( children: [ - // SOC slider Row( children: [ - const Icon(Icons.battery_charging_full, - size: 16, color: AppTheme.accentTeal), + const Icon(Icons.battery_charging_full, size: 16, color: EtmTokens.blue), const SizedBox(width: 6), Text('SOC cible : $targetSoc %', - style: const TextStyle( - fontSize: 12, color: AppTheme.accentTeal)), + style: EtmTokens.sans(size: 12, color: EtmTokens.blue)), Expanded( child: Slider( value: targetSoc.toDouble(), @@ -306,32 +321,23 @@ class _DeadlineParamsRow extends StatelessWidget { max: 100, divisions: 20, label: '$targetSoc %', - activeColor: AppTheme.accentTeal, + activeColor: EtmTokens.blue, onChanged: (v) => onSocChanged(v.round()), ), ), ], ), - // Time picker GestureDetector( onTap: onPickTime, child: Row( children: [ - const Icon(Icons.access_time, - size: 16, color: AppTheme.accentTeal), + const Icon(Icons.access_time, size: 16, color: EtmTokens.blue), const SizedBox(width: 6), - const Text('Heure d\'arrivée :', - style: TextStyle(fontSize: 12, color: AppTheme.accentTeal)), + Text('Heure d\'arrivée :', style: EtmTokens.sans(size: 12, color: EtmTokens.blue)), const SizedBox(width: 8), - Text( - timeLabel, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: AppTheme.accentTeal), - ), + Text(timeLabel, style: EtmTokens.mono(size: 13, color: EtmTokens.blue)), const SizedBox(width: 4), - const Icon(Icons.edit, size: 12, color: AppTheme.accentTeal), + const Icon(Icons.edit, size: 12, color: EtmTokens.blue), ], ), ), @@ -363,23 +369,23 @@ class _ModeButton extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( - color: isSelected ? color : color.withValues(alpha:0.1), - borderRadius: BorderRadius.circular(30), + color: isSelected ? color : color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isSelected ? color : color.withValues(alpha: 0.3)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, - color: isSelected ? Colors.white : color, size: 16), + Icon(icon, color: isSelected ? Colors.white : color, size: 15), const SizedBox(width: 4), Text( label, - style: TextStyle( + style: EtmTokens.sans( + size: 13, + weight: FontWeight.w600, color: isSelected ? Colors.white : color, - fontWeight: FontWeight.bold, - fontSize: 13, ), ), ], @@ -390,92 +396,49 @@ class _ModeButton extends StatelessWidget { } } -class _InfoRow extends StatelessWidget { - final String label; - final String value; - final Color? valueColor; - - const _InfoRow({required this.label, required this.value, this.valueColor}); +/// Barre de progression SOC de la voiture avec target et valeur courante. +class _SocProgress extends StatelessWidget { + final int targetSoc; + final int currentSoc; + const _SocProgress({required this.targetSoc, required this.currentSoc}); @override Widget build(BuildContext context) { - return Row( - children: [ - Text(label, - style: const TextStyle(fontSize: 13, color: AppTheme.textLight)), - const Text(' : ', style: TextStyle(color: AppTheme.textLight)), - Text( - value, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: valueColor ?? AppTheme.textDark, + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: EtmTokens.blueSoft, + borderRadius: BorderRadius.circular(13), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Charge prévue jusqu\'à $targetSoc%', + style: EtmTokens.sans(size: 13, color: EtmTokens.navy)), + Text('$currentSoc%', style: EtmTokens.mono(size: 13, color: EtmTokens.blue)), + ], ), - ), - ], - ); - } -} - -class _ChargingIndicator extends StatelessWidget { - final ChargingMode mode; - final double pvPower; - final double chargingPower; - - const _ChargingIndicator({ - required this.mode, - required this.pvPower, - required this.chargingPower, - }); - - @override - Widget build(BuildContext context) { - final pvPercent = (pvPower / (chargingPower * 1000)).clamp(0.0, 1.0); - final gridPercent = mode == ChargingMode.boost ? 1.0 : (1 - pvPercent).clamp(0.0, 1.0); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Répartition charge', - style: TextStyle(fontSize: 12, color: AppTheme.textLight)), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - height: 10, - child: Row( - children: [ - Flexible( - flex: (pvPercent * 100).toInt(), - child: Container(color: AppTheme.solarYellow), - ), - Flexible( - flex: (gridPercent * 100).toInt(), - child: Container(color: Colors.orange.shade300), - ), - ], + const SizedBox(height: 2), + Text('Aujourd\'hui à 07:30', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: SizedBox( + height: 7, + child: LinearProgressIndicator( + value: currentSoc / 100, + backgroundColor: const Color(0xFFD6E6F2), + valueColor: const AlwaysStoppedAnimation(EtmTokens.blue), + ), ), ), - ), - const SizedBox(height: 4), - Row( - children: [ - _legend(AppTheme.solarYellow, 'Solaire'), - const SizedBox(width: 12), - _legend(Colors.orange.shade300, 'Réseau'), - ], - ), - ], + ], + ), ); } - - Widget _legend(Color color, String label) => Row( - children: [ - Container(width: 10, height: 10, color: color), - const SizedBox(width: 4), - Text(label, style: const TextStyle(fontSize: 11, color: AppTheme.textLight)), - ], - ); } class _ChargingSettingsSheet extends StatefulWidget { @@ -503,8 +466,7 @@ class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> { const Text('Paramètres de charge', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 20), - const Text('Courant minimum (A)', - style: TextStyle(fontWeight: FontWeight.w500)), + Text('Courant minimum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Slider( value: _minPower, min: 6, @@ -512,10 +474,9 @@ class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> { divisions: 10, label: '${_minPower.toStringAsFixed(0)} A', onChanged: (v) => setState(() => _minPower = v), - activeColor: AppTheme.primaryGreen, + activeColor: EtmTokens.green, ), - const Text('Courant maximum (A)', - style: TextStyle(fontWeight: FontWeight.w500)), + Text('Courant maximum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Slider( value: _maxPower, min: 6, @@ -523,17 +484,16 @@ class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> { divisions: 26, label: '${_maxPower.toStringAsFixed(0)} A', onChanged: (v) => setState(() => _maxPower = v), - activeColor: AppTheme.primaryGreen, + activeColor: EtmTokens.green, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Programmation horaire', - style: TextStyle(fontWeight: FontWeight.w500)), + Text('Programmation horaire', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Switch( value: _scheduleEnabled, onChanged: (v) => setState(() => _scheduleEnabled = v), - activeThumbColor: AppTheme.primaryGreen, + activeColor: EtmTokens.green, ), ], ), @@ -564,10 +524,9 @@ class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> { child: ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryGreen, + backgroundColor: EtmTokens.green, foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: const Text('Enregistrer'), ), @@ -604,19 +563,15 @@ class _TimePicker extends StatelessWidget { ), child: Row( children: [ - const Icon(Icons.access_time, size: 16, color: AppTheme.textLight), + const Icon(Icons.access_time, size: 16, color: EtmTokens.faint), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: const TextStyle( - fontSize: 11, color: AppTheme.textLight)), + Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), Text( '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark), + style: EtmTokens.mono(size: 13), ), ], ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bf..38dd0bc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..7e7bd77 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,10 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..d385217 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_macos import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f743bee..2329d8c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + url: "https://pub.dev" + source: hosted + version: "1.2.0" collection: dependency: transitive description: @@ -110,6 +126,62 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_test: dependency: "direct dev" description: flutter @@ -128,6 +200,62 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" leak_tracker: dependency: transitive description: @@ -172,10 +300,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -200,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -208,6 +352,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -264,6 +432,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -369,10 +553,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" typed_data: dependency: transitive description: @@ -485,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -493,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index f848941..e166014 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: go_router: ^14.6.3 url_launcher: ^6.3.1 crypto: ^3.0.6 + google_fonts: ^6.2.1 + flutter_staggered_grid_view: ^0.7.0 + flutter_secure_storage: ^9.2.2 dev_dependencies: flutter_test: @@ -67,6 +70,7 @@ flutter: assets: - assets/images/ + - assets/house.svg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..2048c45 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..e17c858 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)