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:
Patrick Schurig ETM-Schurig 2026-05-29 21:51:51 +02:00
parent 0a6cde914c
commit d0a475a5d9
20 changed files with 3939 additions and 2179 deletions

81
assets/house.svg Normal file
View 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

View File

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

View 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,
};
}

View File

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

View File

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

View File

@ -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 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,
);
}

View File

@ -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
View 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),
),
];
}

View File

@ -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;
}

View File

@ -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),
),
],
),

View File

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

View File

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

View File

@ -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"))
}

View File

@ -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"

View File

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

View File

@ -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"));
}

View File

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