Compare commits
4 Commits
0a6cde914c
...
dd798ae85e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd798ae85e | ||
|
|
7d71bea527 | ||
|
|
80500e21e6 | ||
|
|
d0a475a5d9 |
284
CLAUDE.md
284
CLAUDE.md
@ -1,106 +1,30 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Contexte général
|
||||
|
||||
Dossier parent de tous les projets : `/home/etm/Projects/`
|
||||
|
||||
- **`etm_powersync_app/`** (ce projet) — application Flutter HEMS, interface utilisateur
|
||||
- **`etm-nymea/`** — serveur nymea et plugins associés (C++/Qt), le backend
|
||||
|
||||
Ce projet est le **client Flutter** qui communique avec le serveur nymea via JSON-RPC 2.0.
|
||||
Il n'a aucun accès direct aux plugins — tout passe par les APIs JSON-RPC.
|
||||
|
||||
Lire aussi le `CLAUDE.md` du dossier parent (`/home/etm/Projects/CLAUDE.md`) pour le contexte global.
|
||||
# Agent App — `etm_powersync_app`
|
||||
> Lire aussi le `CLAUDE.md` du dossier parent avant de commencer.
|
||||
|
||||
---
|
||||
|
||||
## Commandes
|
||||
## Mon rôle
|
||||
Interface utilisateur Flutter du HEMS ETM-PowerSync.
|
||||
Je communique **exclusivement via JSON-RPC nymea port 4444**.
|
||||
Le gating des features par tier est géré visuellement ici,
|
||||
mais la source de vérité du tier est dans `powersync-optimizer`.
|
||||
|
||||
```bash
|
||||
flutter pub get # installer/mettre à jour les dépendances
|
||||
flutter run # lancer sur device/émulateur connecté
|
||||
flutter build apk # build Android release
|
||||
flutter analyze # analyse statique Dart
|
||||
flutter test # lancer les tests
|
||||
```
|
||||
---
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| Flutter SDK | ^3.11.0 |
|
||||
| State management | `provider ^6.1.2` — `NymeaService` (ChangeNotifier unique) |
|
||||
| State management | `provider ^6.1.2` — `NymeaService` (ChangeNotifier) |
|
||||
| Navigation | `go_router ^14.6.3` — ShellRoute + 25+ routes |
|
||||
| Transport | WebSocket port 4444 / TCP brut port 2222 |
|
||||
| Graphiques | `fl_chart ^0.70.2` |
|
||||
| Persistance locale | `shared_preferences ^2.3.2` |
|
||||
| Sécurité installateur | `crypto ^3.0.6` (PIN SHA-256, lock 30s, auto-lock 10 min) |
|
||||
| Persistance | `shared_preferences ^2.3.2` |
|
||||
| Sécurité installateur | `crypto ^3.0.6` (PIN SHA-256) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### State management — un seul Provider
|
||||
|
||||
`NymeaService` (`lib/services/nymea_service.dart`) est le seul `ChangeNotifier`. Il est fourni à la racine dans `main.dart` via `ChangeNotifierProvider`. Tous les écrans le consomment avec `context.watch<NymeaService>()` ou `context.read<NymeaService>()`. Il n'y a aucune autre couche de state management.
|
||||
|
||||
### Navigation
|
||||
|
||||
`MainShell` dans `main.dart` utilise un `IndexedStack` de 5 écrans (l'état est préservé entre les onglets) :
|
||||
|
||||
| Index | Écran | Fichier |
|
||||
|-------|-------|---------|
|
||||
| 0 | Dashboard | `lib/screens/dashboard_screen.dart` |
|
||||
| 1 | Énergie | `lib/screens/energy_screen.dart` |
|
||||
| 2 | Things | `lib/screens/things_screen.dart` |
|
||||
| 3 | A/C | `lib/screens/ac_screen.dart` |
|
||||
| 4 | Favoris | `lib/screens/favorites_screen.dart` |
|
||||
|
||||
### Communication avec nymea
|
||||
|
||||
`NymeaService.connect()` supporte deux transports :
|
||||
|
||||
- **TCP brut** port 2222 (défaut) : nymea parle **en premier** (JSON délimité par `\n`). La fragmentation est gérée via `StringBuffer`.
|
||||
- **WebSocket** port 4444 : c'est le **client qui parle en premier** en envoyant `JSONRPC.Hello`.
|
||||
|
||||
Les requêtes utilisent des IDs auto-incrémentés stockés dans `_pendingRequests: Map<int, Completer>`. Timeout global : 15 secondes. Les notifications push nymea (pas de champ `id`, ont un champ `notification`) sont dispatchées dans `_handleNotification()`.
|
||||
|
||||
**Convention nymea énergie importante** : `currentPowerProduction` est **négatif** (producteur = négatif). Le service convertit en positif dans `_parsePowerBalance()`. Puissances en **W**, énergies journalières en **Wh**, totaux API nymea en **kWh** (× 1000 dans le service). Timestamps : **secondes** dans toutes les APIs energy.
|
||||
|
||||
### Mode simulation
|
||||
|
||||
`NymeaService.startSimulation()` déconnecte toute connexion live, active `_isSimulation = true`, génère des things fictifs et une production PV simulée en sinus (timer 2 s). Le dashboard démarre automatiquement la simulation au premier affichage si non connecté. Toutes les méthodes du service font un garde `if (_isSimulation) return …` pour éviter les vrais appels RPC.
|
||||
|
||||
### Modèles de données
|
||||
|
||||
- **`EnergyData`** (`lib/models/energy_data.dart`) — objet immutable avec `copyWith`, regroupe toutes les métriques énergie temps-réel et journalières + état de la borne EV.
|
||||
- **`NymeaThing` / `NymeaThingClass` / `NymeaStateType` / `NymeaActionType`** (`lib/models/nymea_models.dart`) — miroir de l'API Integrations nymea. `NymeaThingClass.primaryStateType` détermine l'état affiché sur les cards.
|
||||
- **`ThingCategory` + `interfaceToCategoryMap`** (`lib/models/thing_category.dart`) — mappe les interfaces nymea (ex. `"evcharger"`, `"solarinverter"`) vers des catégories d'affichage utilisées par `ThingsScreen`.
|
||||
- **`FavoriteWidget`** (`lib/models/nymea_models.dart`) — descripteurs de widgets stockés en mémoire dans `NymeaService._favoriteWidgets`.
|
||||
|
||||
### Thème
|
||||
|
||||
`AppTheme` (`lib/theme/app_theme.dart`) expose des constantes `Color` nommées. Toujours utiliser ces constantes plutôt que des valeurs hex brutes :
|
||||
|
||||
```dart
|
||||
primaryGreen // principal
|
||||
solarYellow // production PV
|
||||
gridGray // réseau
|
||||
homeBlue // consommation maison
|
||||
batteryGreen // batterie
|
||||
boostRed // mode boost
|
||||
pvGreen // surplus PV
|
||||
minPvBlue // mode minPV
|
||||
accentTeal // accent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Bug critique — priorité absolue
|
||||
|
||||
`EVChargingCard` appelle `Energy.SetChargingMode` — cette méthode appartient à l'experience plugin et concerne le comptage du rootmeter, **pas le pilotage EV**. Le SmartChargingManager n'est jamais déclenché depuis l'app dans l'état actuel.
|
||||
## 🔴 Bug critique — à corriger en priorité absolue
|
||||
|
||||
```dart
|
||||
// ❌ FAUX — ne déclenche pas le SmartChargingManager
|
||||
@ -110,64 +34,65 @@ nymeaService.call('Energy.SetChargingMode', {'mode': 'pv'});
|
||||
nymeaService.call('EnergyPlugin.SetChargingInfo', {
|
||||
'chargingInfo': {
|
||||
'evChargerId': chargerId,
|
||||
'mode': 'Eco', // Normal | Eco | EcoWithTargetTime
|
||||
'targetSoc': 80,
|
||||
'endTime': null,
|
||||
'mode': 'Eco', // Eco | EcoWithMinCurrent | Normal
|
||||
// EcoWithTargetTime | EcoMinWithTargetTime
|
||||
'minCurrent': 6, // requis pour EcoWithMinCurrent/*
|
||||
'targetSoc': 80, // requis pour *WithTargetTime
|
||||
'endTime': null, // requis pour *WithTargetTime
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Modes UI EV — 3 boutons + option deadline
|
||||
```
|
||||
[PV] [Min+PV] [Boost]
|
||||
↓
|
||||
Option deadline ? → afficher champs SOC cible + heure d'arrivée
|
||||
|
||||
PV → mode: Eco
|
||||
Min+PV → mode: EcoWithMinCurrent, minCurrent: 6
|
||||
Boost → mode: Normal
|
||||
PV + deadline → mode: EcoWithTargetTime, targetSoc, endTime
|
||||
Min+PV+deadline → mode: EcoMinWithTargetTime, minCurrent, targetSoc, endTime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## APIs JSON-RPC consommées
|
||||
|
||||
### `Energy.*` (nymea-experience-plugin-energy)
|
||||
### `Energy.*` — nymea-experience-plugin-energy
|
||||
| Méthode | Usage | Tier |
|
||||
|---|---|---|
|
||||
| `GetPowerBalance` | Dashboard temps réel | Community |
|
||||
| `GetPowerBalanceLogs(sampleRate, from, to)` | Historique — envoyer les bons `from/to` pour 90j | Community |
|
||||
| `GetThingPowerLogs(sampleRate, thingIds[], from, to)` | Historique par device | Community |
|
||||
| `SetRootMeter(rootMeterThingId)` | Config installateur | Community |
|
||||
|
||||
| Méthode | Usage |
|
||||
|---|---|
|
||||
| `GetPowerBalance` | Dashboard temps réel |
|
||||
| `GetPowerBalanceLogs(sampleRate, from, to)` | Historique — envoyer les bons `from/to` pour les 90j |
|
||||
| `GetThingPowerLogs(sampleRate, thingIds[], from, to)` | Historique par device |
|
||||
| `SetRootMeter(rootMeterThingId)` | Config installateur |
|
||||
### `EnergyPlugin.*` — powersync-energy-plugin-etm
|
||||
| Méthode | Usage | Tier |
|
||||
|---|---|---|
|
||||
| `GetChargingInfos(evChargerId)` | Lire config EV | Community |
|
||||
| `SetChargingInfo(chargingInfo)` | **⚠️ CORRECTION CRITIQUE** | Community |
|
||||
| `GetChargingSchedules(evChargerId)` | Planning EV | Community |
|
||||
| `GetAvailableSpotMarketProviders()` | Liste providers | Community |
|
||||
| `SetSpotMarketConfiguration(enabled, providerId)` | Config tarif dynamique | Predict AI |
|
||||
| `GetSpotMarketScoreEntries(date)` | Cotations aWATTar | Predict AI |
|
||||
| `SetPhasePowerLimit(Uint)` | Config installateur | Community |
|
||||
| `SetAcquisitionTolerance(Double)` | Seuil surplus | Community |
|
||||
|
||||
**Notifications push :** `PowerBalanceChanged`, `PowerBalanceLogEntryAdded`, `ThingPowerLogEntryAdded`, `RootMeterChanged`
|
||||
### `AirConditioning.*` — nymea-experience-plugin-airconditioning
|
||||
| Méthode | Usage | Tier |
|
||||
|---|---|---|
|
||||
| `GetZones` | Afficher zones PAC/thermostat | Auto |
|
||||
| `SetZoneSetpointOverride` | Pilotage manuel | Auto |
|
||||
| `SetZoneWeekSchedule` | Planning 7 jours | Auto |
|
||||
|
||||
### `EnergyPlugin.*` (powersync-energy-plugin-etm)
|
||||
|
||||
| Méthode | Usage |
|
||||
|---|---|
|
||||
| `GetChargingInfos(o:evChargerId)` | Lire config recharge EV |
|
||||
| `SetChargingInfo(chargingInfo)` | **Piloter le smart charging EV** (Eco / EcoWithTargetTime / Normal) |
|
||||
| `GetChargingSchedules(o:evChargerId)` | Afficher le planning EV calculé |
|
||||
| `GetAvailableSpotMarketProviders()` | Liste des fournisseurs tarifs disponibles |
|
||||
| `SetSpotMarketConfiguration(enabled, providerId)` | Activer/choisir fournisseur spot market |
|
||||
| `GetSpotMarketScoreEntries(o:date)` | Cotations horaires aWATTar |
|
||||
| `SetPhasePowerLimit(Uint)` | Config installateur — protection surcharge (A/phase) |
|
||||
| `SetAcquisitionTolerance(Double)` | Config seuil surplus déclenchant la recharge |
|
||||
| `SetBatteryLevelConsideration(Double)` | Facteur batterie dans le calcul surplus |
|
||||
|
||||
**Notifications push :** `ChargingInfoAdded/Removed/Changed`, `ChargingSchedulesChanged`, `SpotMarketConfigurationChanged`, `SpotMarketScoreEntriesChanged`, `PhasePowerLimitChanged`
|
||||
|
||||
### `AirConditioning.*` (nymea-experience-plugin-airconditioning)
|
||||
|
||||
| Méthode | Usage |
|
||||
|---|---|
|
||||
| `GetZones` | Afficher les zones PAC/thermostat |
|
||||
| `AddZone` / `RemoveZone` | Gestion des zones |
|
||||
| `SetZoneName` / `SetZoneThings` | Configuration zone |
|
||||
| `SetZoneStandbySetpoint` | Consigne hors-horaire |
|
||||
| `SetZoneSetpointOverride` | Pilotage manuel zone |
|
||||
| `SetZoneWeekSchedule` | Planning 7 jours |
|
||||
|
||||
**Notifications push :** `ZoneAdded`, `ZoneRemoved`, `ZoneChanged`
|
||||
|
||||
### `Integrations.*` / `Rules.*` / `Logging.*`
|
||||
|
||||
- `GetThings`, `GetThingClasses`, `ExecuteAction`, `SetStateValue`, `SetThingSettings`, `GetStateValue` — ✅ déjà implémentés
|
||||
- `DiscoverThings`, `AddThing`, `RemoveThing`, `EditThing` — ✅ déjà implémentés
|
||||
- `GetRules` — ✅ lecture seule déjà implémentée
|
||||
- `AddRule`, `RemoveRule`, `EditRule` — ❌ à implémenter (UI automatisations Community)
|
||||
- `Logging.GetLogEntries` — ✅ implémenté (historique SOC batterie, température)
|
||||
### `Rules.*` — nymea core
|
||||
| Méthode | Usage | Tier |
|
||||
|---|---|---|
|
||||
| `GetRules` | Lecture automatisations | Community |
|
||||
| `AddRule` | Créer règle HP/HC, surplus → relais | Community |
|
||||
| `RemoveRule / EditRule` | Gérer règles | Community |
|
||||
|
||||
---
|
||||
|
||||
@ -175,87 +100,76 @@ nymeaService.call('EnergyPlugin.SetChargingInfo', {
|
||||
|
||||
| Écran | État | Action requise |
|
||||
|---|---|---|
|
||||
| Dashboard (Sankey + gains + EV card) | ✅ | Icône notifications à brancher |
|
||||
| EnergyScreen (4 onglets + charts) | ✅ | Ajouter sélecteur plage 90j |
|
||||
| ThingsScreen + ThingDetailScreen | ✅ | — |
|
||||
| FavoritesScreen | ✅ | **Persister dans SharedPreferences** |
|
||||
| Dashboard (Sankey + EV card) | ✅ | Corriger API EV + brancher notifications |
|
||||
| EnergyScreen (4 onglets) | ✅ | Ajouter sélecteur plage 90j |
|
||||
| ThingsScreen + ThingDetail | ✅ | — |
|
||||
| FavoritesScreen | ✅ | Persister dans SharedPreferences |
|
||||
| InstallerMode (PIN SHA-256) | ✅ | — |
|
||||
| RoleConfigFlow wizard | ⚠️ Stub | Brancher Step 3 sur les vrais RPC |
|
||||
| TariffScreen | ⚠️ Stub | Brancher sur `EnergyPlugin.SetSpotMarketConfiguration` |
|
||||
| SchedulerScreen | ⚠️ Stub | `setStrategy()` / `forceRecalc()` = fonctions vides |
|
||||
| TimelineScreen | ⚠️ Stub | Données simulées → brancher scheduler réel |
|
||||
| AirConditioning zones | ❌ Absent | À créer (tier Auto) |
|
||||
| DeveloperScreen | ❌ Vide | À créer |
|
||||
| AboutScreen | ❌ Vide | À créer |
|
||||
| RoleConfigFlow wizard | ⚠️ Stub | Brancher sur vrais RPC |
|
||||
| TariffScreen | ⚠️ Stub | Brancher `SetSpotMarketConfiguration` |
|
||||
| SchedulerScreen | ⚠️ Stub | Brancher `GetChargingSchedules` |
|
||||
| TimelineScreen | ⚠️ Stub | Brancher scheduler réel |
|
||||
| AirConditioning zones | ❌ Absent | Créer (Auto) |
|
||||
| Rules UI (automatisations) | ❌ Absent | Créer (Community) |
|
||||
| DeveloperScreen | ❌ Vide | Créer |
|
||||
| AboutScreen | ❌ Vide | Créer |
|
||||
|
||||
---
|
||||
|
||||
## Persistance
|
||||
## Persistance — tout doit survivre au redémarrage
|
||||
|
||||
| Donnée | Actuellement | À faire |
|
||||
| Donnée | État | Action |
|
||||
|---|---|---|
|
||||
| Adresse serveur, PIN, préférences UI | ✅ SharedPreferences | — |
|
||||
| `RoleAssignments` (configuration EMS) | ❌ En mémoire | Persister SharedPreferences |
|
||||
| `FavoriteWidgets` | ❌ En mémoire | Persister SharedPreferences |
|
||||
| `TariffConfig` / `HcHpConfig` | ❌ En mémoire | Persister SharedPreferences |
|
||||
| `SchedulerConfig` | ❌ En mémoire | Persister SharedPreferences |
|
||||
| `RoleAssignments` | ❌ Mémoire | Persister SharedPreferences |
|
||||
| `FavoriteWidgets` | ❌ Mémoire | Persister SharedPreferences |
|
||||
| `TariffConfig` / `HcHpConfig` | ❌ Mémoire | Persister SharedPreferences |
|
||||
| `SchedulerConfig` | ❌ Mémoire | Persister SharedPreferences |
|
||||
|
||||
---
|
||||
|
||||
## Feature gating par tier
|
||||
|
||||
Le tier actif est lu depuis `/etc/powersync/tier.conf` via un futur RPC.
|
||||
En attendant, utiliser `TierProvider` (classe à créer) avec valeur par défaut `community`.
|
||||
|
||||
Utiliser `pro_lock_badge.dart` (déjà présent) pour verrouiller visuellement les features non disponibles. **Ne jamais hardcoder le tier** — toujours passer par `TierProvider` :
|
||||
|
||||
```dart
|
||||
// Toujours via TierProvider — jamais de valeur hardcodée
|
||||
// TierProvider — à créer, lit le tier depuis le plugin via RPC
|
||||
// En attendant : valeur par défaut 'community'
|
||||
|
||||
if (tierProvider.tier >= Tier.auto) {
|
||||
// afficher feature Auto
|
||||
}
|
||||
|
||||
// Utiliser pro_lock_badge.dart (déjà présent) pour verrouiller visuellement
|
||||
```
|
||||
|
||||
### Features par tier
|
||||
| Feature | Community | Auto | Predict AI |
|
||||
|---|---|---|---|
|
||||
| Dashboard temps réel | ✅ | ✅ | ✅ |
|
||||
| Historique journée en cours | ✅ | ✅ | ✅ |
|
||||
| Config EV (`EnergyPlugin.SetChargingInfo`) | ✅ | ✅ | ✅ |
|
||||
| Config EV (SetChargingInfo) | ✅ | ✅ | ✅ |
|
||||
| Tarif HP/HC statique | ✅ | ✅ | ✅ |
|
||||
| UI automatisations (`Rules.*`) | ✅ | ✅ | ✅ |
|
||||
| UI automatisations (Rules.*) | ✅ | ✅ | ✅ |
|
||||
| Historique 90 jours | 🔒 | ✅ | ✅ |
|
||||
| Wizard onboarding guidé | 🔒 | ✅ | ✅ |
|
||||
| Zones PAC/ECS (`AirConditioning.*`) | 🔒 | ✅ | ✅ |
|
||||
| Wizard onboarding | 🔒 | ✅ | ✅ |
|
||||
| Zones PAC/ECS (AirConditioning.*) | 🔒 | ✅ | ✅ |
|
||||
| Prévision solaire Open-Meteo | 🔒 | ✅ | ✅ |
|
||||
| Notifications d'anomalies | 🔒 | ✅ | ✅ |
|
||||
| Accès distant sécurisé | 🔒 | ✅ | ✅ |
|
||||
| Tarifs dynamiques aWATTar / spot market | 🔒 | 🔒 | ✅ |
|
||||
| ENTSO-E / Tibber | 🔒 | 🔒 | ✅ |
|
||||
| Tarifs dynamiques aWATTar | 🔒 | 🔒 | ✅ |
|
||||
| Accès fonctionnalités beta | 🔒 | 🔒 | ✅ |
|
||||
|
||||
Flavors Android/iOS à configurer : `com.etm-powersync.community` / `.auto` / `.predictai`
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Code** : anglais — **Commentaires** : français
|
||||
- **Nommage** : camelCase variables, PascalCase classes
|
||||
- Les écrans utilisent `Consumer<NymeaService>` ou `context.watch` — ne jamais dupliquer l'état du service dans un `State`
|
||||
- **Unités** : Watts (W) pour les puissances instantanées, Wh pour les énergies journalières. L'API nymea retourne des totaux en kWh que le service multiplie par 1000.
|
||||
## Thème — utiliser `app_theme.dart` systématiquement
|
||||
```dart
|
||||
primaryGreen solarYellow gridGray homeBlue
|
||||
batteryGreen boostRed pvGreen minPvBlue accentTeal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Règles de modification
|
||||
|
||||
- Tout nouvel écran → valider la maquette avec Patrick avant de coder
|
||||
- Tout nouvel appel RPC → vérifier dans `docs/nymea_api.md` que la méthode existe
|
||||
- Ne **jamais** appeler `Energy.SetChargingMode` pour piloter un EV
|
||||
- Toujours tester la persistance : killer l'app et vérifier que les données survivent au redémarrage
|
||||
|
||||
---
|
||||
|
||||
## Documentation API
|
||||
|
||||
- `docs/nymea_api.md` — introspection complète de l'API nymea
|
||||
- `docs/nymea_integrations.md` — namespace Integrations en détail
|
||||
- Tout nouvel écran → valider maquette avec Patrick avant de coder
|
||||
- Tout nouvel appel RPC → vérifier dans `INTERFACE.md` que la méthode existe
|
||||
- Ne jamais appeler `Energy.SetChargingMode` pour piloter un EV
|
||||
- Toujours tester la persistance : killer l'app et vérifier que les données survivent
|
||||
- Flavors à configurer : `com.etm-powersync.community` / `.auto` / `.predictai`
|
||||
|
||||
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),
|
||||
),
|
||||
|
||||
@ -281,6 +281,26 @@ class FavoriteWidget {
|
||||
this.stateTypeId,
|
||||
this.extra = const {},
|
||||
});
|
||||
|
||||
factory FavoriteWidget.fromJson(Map<String, dynamic> j) => FavoriteWidget(
|
||||
id: j['id'] as String,
|
||||
type: FavoriteType.values.firstWhere(
|
||||
(t) => t.name == j['type'],
|
||||
orElse: () => FavoriteType.pvPower),
|
||||
title: j['title'] as String,
|
||||
thingId: j['thingId'] as String?,
|
||||
stateTypeId: j['stateTypeId'] as String?,
|
||||
extra: Map<String, dynamic>.from(j['extra'] as Map? ?? {}),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'title': title,
|
||||
if (thingId != null) 'thingId': thingId,
|
||||
if (stateTypeId != null) 'stateTypeId': stateTypeId,
|
||||
if (extra.isNotEmpty) 'extra': extra,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Historique d'un état ───────────────────────────────────────────────────────
|
||||
|
||||
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
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -75,6 +75,36 @@ class NymeaService extends ChangeNotifier {
|
||||
List<NymeaThingClass> _thingClasses = [];
|
||||
List<FavoriteWidget> _favoriteWidgets = [];
|
||||
|
||||
static const _favKey = 'etm_favorites_v1';
|
||||
|
||||
NymeaService() {
|
||||
_loadFavorites();
|
||||
}
|
||||
|
||||
Future<void> _loadFavorites() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_favKey);
|
||||
if (raw != null) {
|
||||
try {
|
||||
final list = jsonDecode(raw) as List;
|
||||
_favoriteWidgets = list
|
||||
.map((e) => FavoriteWidget.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// Données corrompues — on repart de zéro
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveFavorites() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_favKey,
|
||||
jsonEncode(_favoriteWidgets.map((f) => f.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Getters ──────────────────────────────────────────────────────────────────
|
||||
bool get connected => _connected;
|
||||
bool get isConnected => _connected; // alias pour les screens
|
||||
@ -95,9 +125,11 @@ class NymeaService extends ChangeNotifier {
|
||||
|
||||
/// Retourne {thingId, stateName} de la première batterie trouvée,
|
||||
/// ou null si aucune batterie configurée / pas encore chargée.
|
||||
/// Non disponible en simulation (fetchHistory retourne des W, pas des %).
|
||||
Map<String, String>? get batterySOCSource {
|
||||
if (_isSimulation) return null;
|
||||
// En simulation : retourne directement la batterie simulée.
|
||||
if (_isSimulation) {
|
||||
return {'thingId': 'sim-battery', 'stateName': 'soc'};
|
||||
}
|
||||
if (_things.isEmpty) {
|
||||
_log('🔋 batterySOCSource: things pas encore chargés', force: true);
|
||||
return null;
|
||||
@ -1107,14 +1139,24 @@ class NymeaService extends ChangeNotifier {
|
||||
String sampleRate = 'SampleRate15Mins',
|
||||
}) async {
|
||||
if (_isSimulation) {
|
||||
// Données sinusoïdales simulées
|
||||
// SOC simulé : charge dans la journée (solaire) puis décharge le soir.
|
||||
// Valeurs en % (0-100) pour être normalisables côté graphe.
|
||||
final entries = <HistoryEntry>[];
|
||||
const steps = 48;
|
||||
final step = to.difference(from) ~/ steps;
|
||||
for (int i = 0; i <= steps; i++) {
|
||||
final t = from.add(step * i);
|
||||
final v = 500 + 400 * sin(i * pi / 12);
|
||||
entries.add(HistoryEntry(timestamp: t, value: v));
|
||||
final hour = t.hour + t.minute / 60.0;
|
||||
// Montée solaire 8h-15h → 20% → 85%, descente soir → 20%
|
||||
final double soc;
|
||||
if (hour < 8) {
|
||||
soc = 20 + 5 * (hour / 8);
|
||||
} else if (hour < 15) {
|
||||
soc = 25 + 60 * ((hour - 8) / 7);
|
||||
} else {
|
||||
soc = 85 - 65 * ((hour - 15) / 9);
|
||||
}
|
||||
entries.add(HistoryEntry(timestamp: t, value: soc.clamp(5.0, 95.0)));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@ -1185,12 +1227,14 @@ class NymeaService extends ChangeNotifier {
|
||||
if (!_favoriteWidgets.any((f) => f.id == widget.id)) {
|
||||
_favoriteWidgets.add(widget);
|
||||
notifyListeners();
|
||||
_saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
void removeFavorite(String id) {
|
||||
_favoriteWidgets.removeWhere((f) => f.id == id);
|
||||
notifyListeners();
|
||||
_saveFavorites();
|
||||
}
|
||||
|
||||
void reorderFavorites(int oldIndex, int newIndex) {
|
||||
@ -1198,6 +1242,7 @@ class NymeaService extends ChangeNotifier {
|
||||
final item = _favoriteWidgets.removeAt(oldIndex);
|
||||
_favoriteWidgets.insert(newIndex, item);
|
||||
notifyListeners();
|
||||
_saveFavorites();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -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