feat: refonte UI complète — design system EtmTokens + 4 écrans
Design system - lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono) - lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole - app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens Navigation & drawer - DrawerMenuButton : logo vert gradient + ombre - Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif - DrawerPanel 320 px, restyled navy + gradient header + badges rôle Dashboard (01_dashboard.html) - Hero système : status pill + 3 métriques mono + illustration maison CustomPainter - EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles) · gridPower > 0 = soutirage → flèche Grid→Home (amber) · gridPower < 0 = injection → flèche Home→Grid (bleu) - EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar - KPI 2×2 : spark bars, trend line, progress bar - Consommateurs principaux + Décisions d'Héos (chips motifs) - Prévisions placeholder explicite Énergie - KPI 2×2 avec icônes + fond soft + IBM Plex Mono - Sélecteur période vert pill - LineChart double axe : kW (gauche) / SOC % (droite, normalisé) - BarChart bilan énergétique Wh (amber/bleu) - Section Météo & prévision placeholder Things - Grille 2 col à hauteur intrinsèque (pas de childAspectRatio) - Bandeau statut global (simulation / connecté / hors-ligne) - _CategoryCard : header icon+label+count, séparateur coloré, liste tous items - thing_category.dart : couleurs migrées vers EtmTokens A/C — Climatisation / Chauffage - Thermostats pièces EN HAUT : actives expandées, éteintes compactes - Températures actuelle → cible ± avec EtmTokens.mono - Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent) - Chip "Chauffe au solaire en ce moment" (Héos) - Sources pilotées par Héos EN BAS : · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C · Climatiseur : pré-refroidissement anticipé + info solaire Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage Asset : assets/house.svg (illustration maison CustomPainter) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a6cde914c
commit
d0a475a5d9
81
assets/house.svg
Normal file
81
assets/house.svg
Normal file
@ -0,0 +1,81 @@
|
||||
<svg viewBox="0 0 270 200" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#0d2b3b" stroke-width="1.3" stroke-linejoin="round" stroke-linecap="round">
|
||||
<defs>
|
||||
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6f9fb"/><stop offset="1" stop-color="#e7eef3"/></linearGradient>
|
||||
<linearGradient id="side" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#dde6ec"/><stop offset="1" stop-color="#cdd9e1"/></linearGradient>
|
||||
<linearGradient id="roof" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3a4a55"/><stop offset="1" stop-color="#2a363f"/></linearGradient>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#173a52"/><stop offset="1" stop-color="#0f2638"/></linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- ground -->
|
||||
<ellipse cx="138" cy="176" rx="118" ry="13" fill="#e9eef1" stroke="none"/>
|
||||
|
||||
<!-- ===== HEAT PUMP (PAC) right ===== -->
|
||||
<g>
|
||||
<rect x="223" y="138" width="34" height="30" rx="3" fill="url(#side)" stroke="#0d2b3b"/>
|
||||
<line x1="227" y1="145" x2="253" y2="145" stroke="#9aabb4"/>
|
||||
<line x1="227" y1="150" x2="253" y2="150" stroke="#9aabb4"/>
|
||||
<line x1="227" y1="155" x2="253" y2="155" stroke="#9aabb4"/>
|
||||
<circle cx="240" cy="156" r="8" fill="#eef3f6" stroke="#0d2b3b"/>
|
||||
<path d="M240 150c3 2 3 4 0 6M240 162c-3-2-3-4 0-6M234 156c2-3 4-3 6 0M246 156c-2 3-4 3-6 0" stroke="#7a8b97" stroke-width="1"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== HOUSE BODY (two faces for depth) ===== -->
|
||||
<!-- side face -->
|
||||
<path d="M170 96 L200 84 L200 158 L170 168 Z" fill="url(#side)" stroke="#0d2b3b"/>
|
||||
<!-- front face -->
|
||||
<rect x="66" y="96" width="104" height="72" fill="url(#wall)" stroke="#0d2b3b"/>
|
||||
|
||||
<!-- ===== ROOF (gable, two slopes) ===== -->
|
||||
<!-- front slope (carries PV) -->
|
||||
<path d="M58 98 L118 56 L170 56 L122 98 Z" fill="url(#roof)" stroke="#0d2b3b"/>
|
||||
<!-- right slope -->
|
||||
<path d="M118 56 L148 44 L200 44 L170 56 Z" fill="#222d35" stroke="#0d2b3b"/>
|
||||
<!-- side roof edge -->
|
||||
<path d="M170 56 L200 44 L200 86 L170 98 Z" fill="#2f3b44" stroke="#0d2b3b"/>
|
||||
|
||||
<!-- ===== PV PANELS on front slope ===== -->
|
||||
<g stroke="#2f6e96" stroke-width="0.8">
|
||||
<path d="M70 92 L120 60 L162 60 L116 92 Z" fill="url(#panel)" stroke="#0d2b3b" stroke-width="1"/>
|
||||
<!-- cell grid (regular lattice) -->
|
||||
<path d="M81.5 92.0 L130.5 60.0 M93.0 92.0 L141.0 60.0 M104.5 92.0 L151.5 60.0 M86.7 81.3 L131.3 81.3 M103.3 70.7 L146.7 70.7" stroke="#2f6e96"/>
|
||||
<!-- subtle amber sheen -->
|
||||
<path d="M70 92 L120 60 L162 60 L116 92 Z" fill="#fec113" opacity="0.08" stroke="none"/>
|
||||
</g>
|
||||
|
||||
<!-- ridge + soffit -->
|
||||
<line x1="58" y1="98" x2="122" y2="98" stroke="#1a242b"/>
|
||||
|
||||
<!-- ===== DOOR + WINDOW (front) ===== -->
|
||||
<rect x="86" y="128" width="22" height="40" rx="1.5" fill="#c98a4a" stroke="#0d2b3b"/>
|
||||
<circle cx="103" cy="148" r="1.4" fill="#0d2b3b" stroke="none"/>
|
||||
<rect x="124" y="112" width="32" height="22" rx="1.5" fill="#bfe2f3" stroke="#0d2b3b"/>
|
||||
<line x1="140" y1="112" x2="140" y2="134" stroke="#0d2b3b" stroke-width="0.8"/>
|
||||
<line x1="124" y1="123" x2="156" y2="123" stroke="#0d2b3b" stroke-width="0.8"/>
|
||||
|
||||
<!-- ===== WALLBOX (borne) on front-left wall ===== -->
|
||||
<rect x="70" y="120" width="13" height="22" rx="3" fill="#31a3dd" stroke="#0d2b3b"/>
|
||||
<rect x="73.5" y="124" width="6" height="7" rx="1" fill="#eaf6fc" stroke="none"/>
|
||||
<circle cx="76.5" cy="137" r="1.6" fill="#eaf6fc" stroke="none"/>
|
||||
<!-- cable to car -->
|
||||
<path d="M76 142 C70 150 58 150 52 152" stroke="#0d2b3b" stroke-width="1.2"/>
|
||||
|
||||
<!-- ===== EV CAR (front-left) ===== -->
|
||||
<g>
|
||||
<path d="M8 156 Q10 142 26 141 L42 141 Q54 142 58 152 L62 154 Q66 156 62 161 L10 161 Q4 160 6 154 Z" fill="#dbe4ea" stroke="#0d2b3b"/>
|
||||
<path d="M18 144 L40 144 L48 152 L14 152 Z" fill="#bfe2f3" stroke="#0d2b3b" stroke-width="0.9"/>
|
||||
<circle cx="20" cy="161" r="6" fill="#2a363d" stroke="#0d2b3b"/><circle cx="20" cy="161" r="2.2" fill="#9aabb4" stroke="none"/>
|
||||
<circle cx="50" cy="161" r="6" fill="#2a363d" stroke="#0d2b3b"/><circle cx="50" cy="161" r="2.2" fill="#9aabb4" stroke="none"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== thin labels ===== -->
|
||||
<g stroke="none" fill="#6b7d88" font-family="IBM Plex Mono, monospace" font-size="8" font-weight="600">
|
||||
<text x="150" y="33">PV</text>
|
||||
<text x="225" y="132">PAC</text>
|
||||
<text x="34" y="133">BORNE</text>
|
||||
</g>
|
||||
<g stroke="#c4ced4" stroke-width="0.8">
|
||||
<line x1="155" y1="35" x2="140" y2="55"/>
|
||||
<line x1="236" y1="134" x2="240" y2="138"/>
|
||||
<line x1="58" y1="131" x2="70" y2="128"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@ -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<MainShell>
|
||||
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<NavigationProvider>().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),
|
||||
),
|
||||
|
||||
75
lib/models/nymea_user.dart
Normal file
75
lib/models/nymea_user.dart
Normal file
@ -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<NymeaPermission> 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<NymeaPermission> clientScopes = {
|
||||
NymeaPermission.controlThings,
|
||||
NymeaPermission.executeMagic,
|
||||
};
|
||||
static const Set<NymeaPermission> installerScopes = {
|
||||
NymeaPermission.controlThings,
|
||||
NymeaPermission.executeMagic,
|
||||
NymeaPermission.configureThings,
|
||||
NymeaPermission.configureMagic,
|
||||
};
|
||||
static const Set<NymeaPermission> adminScopes = {
|
||||
NymeaPermission.admin,
|
||||
NymeaPermission.controlThings,
|
||||
NymeaPermission.executeMagic,
|
||||
NymeaPermission.configureThings,
|
||||
NymeaPermission.configureMagic,
|
||||
};
|
||||
}
|
||||
@ -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<ThingCategory, ThingCategoryInfo> 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<ThingCategory, ThingCategoryInfo> 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<ThingCategory, ThingCategoryInfo> categoryInfoMap = {
|
||||
category: ThingCategory.other,
|
||||
label: 'Autres',
|
||||
icon: Icons.device_hub_rounded,
|
||||
color: Color(0xFF78909C),
|
||||
color: EtmTokens.faint,
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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<InstallerModeProvider>();
|
||||
|
||||
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<NavigationProvider>().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<InstallerModeProvider>().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<InstallerModeProvider>().lock(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<ThingsScreen> createState() => _ThingsScreenState();
|
||||
}
|
||||
|
||||
class _ThingsScreenState extends State<ThingsScreen> {
|
||||
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<NymeaService>();
|
||||
if (!service.connected) service.startSimulation();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final service = context.watch<NymeaService>();
|
||||
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<NymeaThingClass> 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<NymeaThing> things;
|
||||
final List<NymeaThingClass> 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<NymeaThingClass> 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<ThingCategory> cats;
|
||||
final Map<ThingCategory, List<NymeaThing>> grouped;
|
||||
final List<NymeaThingClass> classes;
|
||||
|
||||
const _CategoryGrid({
|
||||
required this.cats,
|
||||
required this.grouped,
|
||||
required this.classes,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = <Widget>[];
|
||||
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<NymeaThing> things, List<NymeaThingClass> 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<NymeaThing> things;
|
||||
final List<NymeaThingClass> 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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
85
lib/theme/etm_tokens.dart
Normal file
85
lib/theme/etm_tokens.dart
Normal file
@ -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<BoxShadow> 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),
|
||||
),
|
||||
];
|
||||
}
|
||||
@ -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<EnergyFlowWidget> createState() => _EnergyFlowWidgetState();
|
||||
}
|
||||
|
||||
class _EnergyFlowWidgetState extends State<EnergyFlowWidget>
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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<EVChargingCard> {
|
||||
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<EVChargingCard> {
|
||||
_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<EVChargingCard> {
|
||||
],
|
||||
],
|
||||
|
||||
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<Color>(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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
210
pubspec.lock
210
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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,6 +6,12 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user