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 @@
+
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)