feat: historique énergie, navigation Things, actions nymea

Énergie :
- Écran Énergie reécrit : line chart (production/conso/autoconso/batterie)
  et bar chart (bilan Wh par période) avec onglets 15 min / 1 h / 1 j / 1 sem
- Datepicker pour sélectionner une période historique (chip dismissible)
- Timelines des deux graphiques alignées (même x=i → data[i].timestamp)
- PowerBalanceEntry + fetchPowerBalanceLogs() + simulation sinusoïdale
- Overflow fixes : energy_flow_widget (Expanded sur titre), production_card

Things :
- Navigation 3 niveaux : ThingsScreen → CategoryOverviewScreen → ThingDetailScreen
- Catégorie Cars ajoutée, carrousel corrigé (clamp RangeError)
- ThingDetailScreen : executeAction, setStateValue, activeThumbColor fix
- NymeaTile widget, state_history_chart widget (générique Logging.GetLogEntries)

Modèles / service :
- HistoryEntry, PowerBalanceEntry ajoutés
- fetchHistory(), fetchPowerBalanceLogs() dans NymeaService
- interfaceToCategoryMap étendu (Cars, etc.)
- AppTheme : nouvelles couleurs (accentTeal, boostRed, pvGreen, minPvBlue…)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-02-23 07:15:48 +01:00
parent d5dc0c7ca5
commit 8862dc2a72
13 changed files with 2732 additions and 1351 deletions

View File

@ -98,4 +98,35 @@ class HistoryPoint {
this.gridWh = 0,
this.batteryWh = 0,
});
}
// Entrée Energy.GetPowerBalanceLogs
class PowerBalanceEntry {
final DateTime timestamp;
final double productionW; // puissance PV (valeur absolue, W)
final double consumptionW; // consommation maison (W)
final double acquisitionW; // import réseau (W, positif)
final double storageW; // batterie : +charge / -décharge (W)
final double totalProductionWh; // cumulé depuis l'origine (Wh)
final double totalConsumptionWh;
final double totalReturnWh; // injection réseau cumulée (Wh)
final double totalAcquisitionWh;
const PowerBalanceEntry({
required this.timestamp,
this.productionW = 0,
this.consumptionW = 0,
this.acquisitionW = 0,
this.storageW = 0,
this.totalProductionWh = 0,
this.totalConsumptionWh = 0,
this.totalReturnWh = 0,
this.totalAcquisitionWh = 0,
});
/// Autoconsommation instantanée (W) = production locale non injectée au réseau.
double get autoconsommationW =>
(consumptionW + (storageW > 0 ? storageW : 0))
.clamp(0.0, productionW)
.toDouble();
}

View File

@ -71,15 +71,49 @@ class NymeaStateValue {
);
}
// Résultat d'une action nymea ───────────────────────────────────────────────
class NymeaActionResult {
final bool success;
final String thingError; // "ThingErrorNoError" si OK
final String? displayMessage; // message humain optionnel du serveur
const NymeaActionResult({
required this.success,
this.thingError = 'ThingErrorNoError',
this.displayMessage,
});
/// Message d'erreur lisible. Vide si succès.
String get errorText {
if (success) return '';
if (displayMessage != null && displayMessage!.isNotEmpty) return displayMessage!;
const map = {
'ThingErrorNoError': '',
'ThingErrorNotFound': 'Appareil introuvable',
'ThingErrorInvalidParameter': 'Paramètre invalide',
'ThingErrorSetupFailed': 'Échec de configuration',
'ThingErrorHardwareNotAvailable': 'Matériel indisponible',
'ThingErrorActionTypeNotFound': 'Action introuvable',
'ThingErrorStateTypeNotFound': 'État introuvable',
'ThingErrorHardwareFailure': 'Panne matérielle',
'ThingErrorTimeout': 'Délai dépassé',
};
return map[thingError] ?? thingError.replaceAll('ThingError', '');
}
}
// State type definition (from GetThingClasses)
class NymeaStateType {
final String id;
final String name;
final String displayName;
final String type; // "Double", "Bool", "Int", "String"
final String unit; // "W", "V", "A", "Percentage", ""
final String type; // "Double", "Bool", "Int", "String"
final String unit; // "W", "V", "A", "Percentage", ""
final dynamic defaultValue;
final bool writable;
final List<dynamic>? allowedValues; // valeurs possibles pour les états enum
final double? minValue; // borne min pour Int/Double
final double? maxValue; // borne max pour Int/Double
const NymeaStateType({
required this.id,
@ -89,6 +123,9 @@ class NymeaStateType {
required this.unit,
this.defaultValue,
this.writable = false,
this.allowedValues,
this.minValue,
this.maxValue,
});
factory NymeaStateType.fromJson(Map<String, dynamic> j) => NymeaStateType(
@ -99,6 +136,10 @@ class NymeaStateType {
unit: j['unit'] as String? ?? '',
defaultValue: j['defaultValue'],
writable: j['writable'] as bool? ?? false,
allowedValues: (j['allowedValues'] as List?)?.toList()
?? (j['possibleValues'] as List?)?.toList(),
minValue: (j['minValue'] as num?)?.toDouble(),
maxValue: (j['maxValue'] as num?)?.toDouble(),
);
String formatValue(dynamic value) {
@ -123,11 +164,7 @@ class NymeaStateType {
}
String _fmt(dynamic value) {
if (value is double) {
if (value.abs() >= 1000) return value.toStringAsFixed(0);
if (value.abs() >= 10) return value.toStringAsFixed(1);
return value.toStringAsFixed(2);
}
if (value is double) return value.toStringAsFixed(2);
return value.toString();
}
}
@ -245,3 +282,11 @@ class FavoriteWidget {
this.extra = const {},
});
}
// Historique d'un état ───────────────────────────────────────────────────────
class HistoryEntry {
final DateTime timestamp;
final double value;
const HistoryEntry({required this.timestamp, required this.value});
}

View File

@ -10,7 +10,8 @@ enum ThingCategory {
energy, // smartmeter, energymeter, powersocket
solar, // solarinverter, solarpanel
battery, // battery, energystorage
evCharger, // evcharger
evCharger, // evcharger, wallbox
cars, // evvehicle, electricvehicle + class name "car"/"vehicle"
hvac, // thermostat, heatingzone, ac, pump
lighting, // light, dimmablelight, colorlight
sensors, // sensor, temperaturesensor, humiditysensor, motionsensor
@ -55,6 +56,10 @@ const Map<String, ThingCategory> interfaceToCategoryMap = {
'batterymonitor': ThingCategory.battery,
'evcharger': ThingCategory.evCharger,
'wallbox': ThingCategory.evCharger,
'evvehicle': ThingCategory.cars,
'electricvehicle': ThingCategory.cars,
'electriccar': ThingCategory.cars,
'vehicle': ThingCategory.cars,
'thermostat': ThingCategory.hvac,
'heatingzone': ThingCategory.hvac,
'ac': ThingCategory.hvac,
@ -120,6 +125,12 @@ const Map<ThingCategory, ThingCategoryInfo> categoryInfoMap = {
icon: Icons.electric_car_rounded,
color: AppTheme.minPvBlue,
),
ThingCategory.cars: ThingCategoryInfo(
category: ThingCategory.cars,
label: 'Cars',
icon: Icons.directions_car_rounded,
color: AppTheme.accentTeal,
),
ThingCategory.hvac: ThingCategoryInfo(
category: ThingCategory.hvac,
label: 'Chauffage & Climatisation',
@ -180,6 +191,11 @@ extension ThingClassCategoryExt on NymeaThingClass {
final cat = interfaceToCategoryMap[iface.toLowerCase()];
if (cat != null) return cat;
}
// Fallback : nom de classe contient "car" ou "vehicle" catégorie Cars
final lower = name.toLowerCase();
if (lower.contains('car') || lower.contains('vehicle')) {
return ThingCategory.cars;
}
return ThingCategory.other;
}

View File

@ -0,0 +1,272 @@
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 'thing_detail_screen.dart';
//
// CategoryOverviewScreen NIVEAU 2
//
// Liste des things d'une catégorie.
// Reproduit smart-meters_overview.png (nymea-app) :
//
// AppBar : [NOM CATÉGORIE] (centré)
//
// Pour chaque thing :
//
// Nom du thing (bold) fond coloré léger
//
// [icône] Classe appareil valeur fond blanc
//
//
class CategoryOverviewScreen extends StatelessWidget {
final ThingCategoryInfo info;
final List<NymeaThing> things;
final List<NymeaThingClass> thingClasses;
const CategoryOverviewScreen({
super.key,
required this.info,
required this.things,
required this.thingClasses,
});
// Helpers
NymeaThingClass? _cls(NymeaThing t) {
try {
return thingClasses.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');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
String? _primaryValue(NymeaThing t, NymeaThingClass? cls) {
final p = cls?.primaryStateType;
if (p == null) return null;
return p.formatValue(t.stateValue(p.id));
}
// Build
@override
Widget build(BuildContext context) {
// Écoute les mises à jour live via le service
final service = context.watch<NymeaService>();
final liveThings = things.map((t) {
try {
return service.things.firstWhere((lt) => lt.id == t.id);
} catch (_) {
return t;
}
}).toList();
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.chevron_left,
color: AppTheme.textDark, size: 30),
onPressed: () => Navigator.pop(context),
),
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(info.icon, color: info.color, size: 20),
const SizedBox(width: 8),
Text(
info.label,
style: const TextStyle(
color: AppTheme.textDark,
fontWeight: FontWeight.bold,
fontSize: 18),
),
],
),
),
body: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 60),
itemCount: liveThings.length,
separatorBuilder: (context, i) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final t = liveThings[i];
final cls = _cls(t);
return _ThingListCard(
thing: t,
thingClass: cls,
info: info,
isOnline: _isOnline(t, cls),
primaryValue: _primaryValue(t, cls),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => ThingDetailScreen(thing: t)),
),
);
},
),
);
}
}
//
// _ThingListCard Card deux lignes style nymea
//
class _ThingListCard extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
final ThingCategoryInfo info;
final bool isOnline;
final String? primaryValue;
final VoidCallback onTap;
const _ThingListCard({
required this.thing,
required this.thingClass,
required this.info,
required this.isOnline,
required this.primaryValue,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
border: Border.all(
color: info.color.withValues(alpha: 0.28), width: 1),
),
child: Column(
children: [
// Ligne 1 : nom du thing
Container(
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.10),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(9)),
),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 11),
child: Row(
children: [
Expanded(
child: Text(
thing.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textDark),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Pastille statut
Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color:
isOnline ? info.color : Colors.grey[400],
shape: BoxShape.circle,
boxShadow: isOnline
? [
BoxShadow(
color: info.color
.withValues(alpha: 0.45),
blurRadius: 4,
)
]
: null,
),
),
],
),
),
// Ligne 2 : icône + classe + valeur primaire
Container(
color: AppTheme.cardWhite,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: Row(
children: [
// Icône
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: info.color
.withValues(alpha: isOnline ? 0.12 : 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
info.icon,
size: 18,
color: isOnline
? info.color
: info.color.withValues(alpha: 0.4),
),
),
const SizedBox(width: 12),
// Classe appareil
Expanded(
child: Text(
thingClass?.displayName ?? 'Appareil',
style: const TextStyle(
fontSize: 13, color: AppTheme.textLight),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Valeur primaire
Text(
primaryValue ?? '',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isOnline
? info.color
: AppTheme.textLight,
),
),
const SizedBox(width: 4),
// Flèche
const Icon(Icons.chevron_right,
size: 18, color: AppTheme.textLight),
],
),
),
],
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,82 +1,88 @@
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 'thing_detail_screen.dart';
import 'category_overview_screen.dart';
class ThingsScreen extends StatefulWidget {
//
// 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.
//
class ThingsScreen extends StatelessWidget {
const ThingsScreen({super.key});
@override
State<ThingsScreen> createState() => _ThingsScreenState();
}
class _ThingsScreenState extends State<ThingsScreen> {
final Set<ThingCategory> _collapsed = {};
String _searchQuery = '';
bool _showSearch = false;
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.other,
];
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
// Garder tous les things avec un id valide (le filtre setupStatus était trop strict)
final things = service.things.where((t) => t.id.isNotEmpty).toList();
final thingClasses = service.thingClasses;
final filtered = _searchQuery.isEmpty
? things
: things.where((t) =>
t.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
final classes = service.thingClasses;
// Groupement par catégorie
final Map<ThingCategory, List<NymeaThing>> grouped = {};
for (final thing in filtered) {
final cls = _classFor(thing, thingClasses);
for (final t in things) {
final cls = _classFor(t, classes);
final cat = cls?.category ?? ThingCategory.other;
grouped.putIfAbsent(cat, () => []).add(thing);
grouped.putIfAbsent(cat, () => []).add(t);
}
const orderedCats = [
ThingCategory.energy, ThingCategory.solar, ThingCategory.battery,
ThingCategory.evCharger, ThingCategory.hvac, ThingCategory.lighting,
ThingCategory.sensors, ThingCategory.network, ThingCategory.notifications,
ThingCategory.weather, ThingCategory.media, ThingCategory.other,
];
final cats = orderedCats.where((c) => grouped.containsKey(c)).toList();
final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList();
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: _buildAppBar(service, things.length),
body: !service.isConnected
? _buildDisconnected()
: (things.isEmpty && !service.thingsLoaded)
? _buildLoading()
? _buildPlaceholder(
icon: Icons.cloud_off_rounded,
label: 'Non connecté à nymea',
)
: things.isEmpty && !service.thingsLoaded
? const Center(
child: CircularProgressIndicator(color: AppTheme.accentTeal))
: things.isEmpty
? _buildEmpty()
: cats.isEmpty
// Classes pas encore chargées afficher things en mode simple
? _buildThingsSimple(things, service)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: cats.length,
? _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[i];
return _CategorySection(
info: categoryInfoMap[cat]!,
things: grouped[cat]!,
thingClasses: thingClasses,
collapsed: _collapsed.contains(cat),
onToggle: () => setState(() {
if (_collapsed.contains(cat)) {
_collapsed.remove(cat);
} else {
_collapsed.add(cat);
}
}),
onTap: (t) => Navigator.push(
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: (_) => ThingDetailScreen(thing: t)),
builder: (_) => CategoryOverviewScreen(
info: info,
things: catThings,
thingClasses: classes,
),
),
),
);
},
@ -88,46 +94,27 @@ class _ThingsScreenState extends State<ThingsScreen> {
return AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: _showSearch
? TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: 'Rechercher un appareil...',
border: InputBorder.none,
hintStyle: TextStyle(color: AppTheme.textLight),
),
style: const TextStyle(color: AppTheme.textDark, fontSize: 18),
onChanged: (v) => setState(() => _searchQuery = v),
)
: 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.primaryGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text('$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.primaryGreen)),
),
]),
actions: [
IconButton(
icon: Icon(_showSearch ? Icons.close : Icons.search,
color: AppTheme.textDark),
onPressed: () => setState(() {
_showSearch = !_showSearch;
_searchQuery = '';
}),
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('$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(),
@ -136,174 +123,259 @@ class _ThingsScreenState extends State<ThingsScreen> {
);
}
Widget _buildLoading() => const Center(
static Widget _buildPlaceholder(
{required IconData icon, required String label}) =>
Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
CircularProgressIndicator(color: AppTheme.primaryGreen),
SizedBox(height: 16),
Text('Chargement des appareils...',
style: TextStyle(color: AppTheme.textLight)),
]),
);
Widget _buildDisconnected() => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.cloud_off_rounded, size: 64, color: Colors.grey[400]),
Icon(icon, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
const Text('Non connecté à nymea',
style: TextStyle(color: AppTheme.textLight, fontSize: 16)),
Text(label,
style:
const TextStyle(color: AppTheme.textLight, fontSize: 16)),
]),
);
/// Affichage simple quand les ThingClasses ne sont pas encore chargées
Widget _buildThingsSimple(List<NymeaThing> things, NymeaService service) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: things.length,
itemBuilder: (context, i) {
final t = things[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
leading: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.device_hub_rounded,
color: AppTheme.primaryGreen, size: 22),
),
title: Text(t.name,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: Text(t.setupStatus.replaceAll('ThingSetupStatus', ''),
style: const TextStyle(fontSize: 12, color: AppTheme.textLight)),
trailing: t.setupStatus == 'ThingSetupStatusComplete'
? Container(
width: 8, height: 8,
decoration: const BoxDecoration(
color: AppTheme.primaryGreen,
shape: BoxShape.circle,
),
)
: null,
),
);
},
);
}
Widget _buildEmpty() => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.search_off_rounded, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
_searchQuery.isEmpty ? 'Aucun appareil' : 'Aucun résultat pour "$_searchQuery"',
style: const TextStyle(color: AppTheme.textLight, fontSize: 16),
),
]),
);
NymeaThingClass? _classFor(NymeaThing thing, List<NymeaThingClass> classes) {
try { return classes.firstWhere((c) => c.id == thing.thingClassId); }
catch (_) { return null; }
static NymeaThingClass? _classFor(
NymeaThing t, List<NymeaThingClass> classes) {
try {
return classes.firstWhere((c) => c.id == t.thingClassId);
} catch (_) {
return null;
}
}
}
// Section catégorie
//
// _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 _CategorySection extends StatelessWidget {
class _CategoryTile extends StatefulWidget {
final ThingCategoryInfo info;
final List<NymeaThing> things;
final List<NymeaThingClass> thingClasses;
final bool collapsed;
final VoidCallback onToggle;
final void Function(NymeaThing) onTap;
final VoidCallback onTap;
const _CategorySection({
required this.info, required this.things, required this.thingClasses,
required this.collapsed, required this.onToggle, required this.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 thingClasses.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
try {
return widget.thingClasses.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');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
String? _primaryValue(NymeaThing t, NymeaThingClass? cls) {
final p = cls?.primaryStateType;
if (p == null) return null;
return p.formatValue(t.stateValue(p.id));
}
@override
Widget build(BuildContext context) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Header collapsable
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9),
),
child: Icon(info.icon, color: info.color, size: 19),
),
const SizedBox(width: 10),
Expanded(
child: Text(info.label,
style: const TextStyle(
fontWeight: FontWeight.w700, fontSize: 13,
color: AppTheme.textDark, letterSpacing: 0.2)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text('${things.length}',
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.bold,
color: info.color)),
),
const SizedBox(width: 4),
AnimatedRotation(
turns: collapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more,
color: AppTheme.textLight, size: 20),
),
]),
),
),
// Clamp _idx au cas la liste shrink entre deux ticks du timer
final safeIdx = _idx.clamp(0, widget.things.length - 1);
final current = widget.things[safeIdx];
final cls = _cls(current);
final online = _isOnline(current, cls);
final value = _primaryValue(current, cls);
final info = widget.info;
// Things
AnimatedCrossFade(
duration: const Duration(milliseconds: 220),
crossFadeState: collapsed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: Column(
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: [
...things.map((t) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ThingCard(
thing: t,
thingClass: _cls(t),
categoryInfo: info,
onTap: () => onTap(t),
// 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,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
)),
const SizedBox(height: 4),
),
// 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,
),
),
),
),
],
),
secondChild: const SizedBox.shrink(),
),
]);
);
}
}
// ThingCard
// 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
//
class ThingCard extends StatelessWidget {
final NymeaThing thing;
@ -312,285 +384,23 @@ class ThingCard extends StatelessWidget {
final VoidCallback onTap;
const ThingCard({
super.key, required this.thing, required this.thingClass,
required this.categoryInfo, required this.onTap,
super.key,
required this.thing,
required this.thingClass,
required this.categoryInfo,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final primaryType = thingClass?.primaryStateType;
final primaryValue = primaryType?.formatValue(thing.stateValue(primaryType.id));
final secondaryStates = _secondaryStates();
final isOnline = _isOnline();
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Ligne principale
Row(children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: categoryInfo.color.withValues(alpha: isOnline ? 0.15 : 0.07),
borderRadius: BorderRadius.circular(13),
),
child: Icon(categoryInfo.icon,
color: isOnline
? categoryInfo.color
: categoryInfo.color.withValues(alpha: 0.4),
size: 26),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(thing.name,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 15,
color: AppTheme.textDark),
maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(thingClass?.displayName ?? 'Appareil',
style: const TextStyle(fontSize: 12, color: AppTheme.textLight),
maxLines: 1, overflow: TextOverflow.ellipsis),
]),
),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
if (primaryValue != null)
Text(primaryValue,
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 16,
color: isOnline
? categoryInfo.color
: AppTheme.textLight)),
const SizedBox(height: 4),
_StatusDot(isOnline: isOnline),
]),
const SizedBox(width: 2),
_ThingMenu(thing: thing, onDetail: onTap),
]),
// Chips états secondaires
if (secondaryStates.isNotEmpty) ...[
const SizedBox(height: 10),
const Divider(height: 1, color: Color(0xFFEEEEEE)),
const SizedBox(height: 10),
Wrap(
spacing: 7, runSpacing: 6,
children: secondaryStates.map((s) => _StateChip(
label: s.displayName,
value: s.formatValue(thing.stateValue(s.id)),
color: categoryInfo.color,
)).toList(),
),
],
]),
),
),
final primary = thingClass?.primaryStateType;
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,
onTap: onTap,
);
}
bool _isOnline() {
final connType = thingClass?.stateTypes
.where((s) => s.name.toLowerCase() == 'connected');
if (connType != null && connType.isNotEmpty) {
final val = thing.stateValue(connType.first.id);
return val == true || val == 'true';
}
return thing.isSetupComplete;
}
List<NymeaStateType> _secondaryStates() {
if (thingClass == null) return [];
final primary = thingClass!.primaryStateType;
const interesting = [
'voltage', 'current', 'frequency', 'power',
'totalenergyconsumed', 'totalenergyproduced',
'temperature', 'humidity', 'soc',
'chargingmode', 'mode', 'maxchargingcurrent', 'status',
];
return thingClass!.stateTypes
.where((s) =>
s != primary &&
s.name.toLowerCase() != 'connected' &&
interesting.any((i) => s.name.toLowerCase().contains(i)))
.take(3)
.toList();
}
}
// Widgets utilitaires
class _StatusDot extends StatelessWidget {
final bool isOnline;
const _StatusDot({required this.isOnline});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 7, height: 7,
decoration: BoxDecoration(
color: isOnline ? AppTheme.primaryGreen : Colors.grey[400],
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
isOnline ? 'En ligne' : 'Hors ligne',
style: TextStyle(
fontSize: 10,
color: isOnline ? AppTheme.primaryGreen : AppTheme.textLight),
),
]);
}
class _StateChip extends StatelessWidget {
final String label;
final String value;
final Color color;
const _StateChip({required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: Text(label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11, color: color.withValues(alpha: 0.8),
fontWeight: FontWeight.w500)),
),
const SizedBox(width: 5),
Flexible(
child: Text(value,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.bold)),
),
]),
);
}
// Menu contextuel
class _ThingMenu extends StatelessWidget {
final NymeaThing thing;
final VoidCallback onDetail;
const _ThingMenu({required this.thing, required this.onDetail});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: AppTheme.textLight, size: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (_) => [
const PopupMenuItem(value: 'detail',
child: Row(children: [
Icon(Icons.info_outline, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Détails & États'),
])),
const PopupMenuItem(value: 'params',
child: Row(children: [
Icon(Icons.tune, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Paramètres'),
])),
const PopupMenuItem(value: 'rename',
child: Row(children: [
Icon(Icons.edit_outlined, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Renommer'),
])),
const PopupMenuItem(value: 'favorite',
child: Row(children: [
Icon(Icons.star_outline, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Ajouter aux favoris'),
])),
],
onSelected: (value) {
switch (value) {
case 'detail': onDetail(); break;
case 'params': _showParamsSheet(context); break;
case 'rename': _showRenameDialog(context); break;
case 'favorite':
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${thing.name} ajouté aux favoris'),
backgroundColor: AppTheme.primaryGreen,
duration: const Duration(seconds: 2),
));
}
},
);
}
void _showParamsSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (_) => Padding(
padding: const EdgeInsets.all(20),
child: Column(mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.tune, color: AppTheme.primaryGreen),
const SizedBox(width: 8),
Expanded(child: Text('Paramètres — ${thing.name}',
style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 16, color: AppTheme.textDark))),
]),
const SizedBox(height: 4),
SelectableText('ID: ${thing.id}',
style: const TextStyle(fontSize: 10, color: AppTheme.textLight)),
const Divider(height: 24),
if (thing.paramValues.isEmpty)
const Text('Aucun paramètre configuré.',
style: TextStyle(color: AppTheme.textLight))
else
...thing.paramValues.map((p) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Expanded(child: Text(p['paramTypeId']?.toString() ?? '?',
style: const TextStyle(color: AppTheme.textLight))),
Text(p['value']?.toString() ?? '',
style: const TextStyle(
fontWeight: FontWeight.w600, color: AppTheme.textDark)),
]),
)),
const SizedBox(height: 20),
]),
),
);
}
void _showRenameDialog(BuildContext context) {
final ctrl = TextEditingController(text: thing.name);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Renommer'),
content: TextField(
controller: ctrl, autofocus: true,
decoration: const InputDecoration(labelText: 'Nouveau nom'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')),
FilledButton(
onPressed: () {
context.read<NymeaService>().renameThing(thing.id, ctrl.text.trim());
Navigator.pop(ctx);
},
child: const Text('Renommer'),
),
],
),
);
}
}

View File

@ -6,7 +6,7 @@ import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/energy_data.dart';
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
import '../models/nymea_models.dart';
// Protocole de connexion
@ -558,6 +558,87 @@ class NymeaService extends ChangeNotifier {
Future<void> refreshEnergy() => _loadEnergyStatus();
/// Récupère les logs de bilan énergétique via Energy.GetPowerBalanceLogs.
///
/// [sampleRate] : "SampleRate15Mins" | "SampleRate1Hour" |
/// "SampleRate3Hours" | "SampleRate1Day" | "SampleRate1Week"
/// Timestamps envoyés en **secondes** (convention nymea Energy API).
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
required DateTime from,
required DateTime to,
String sampleRate = 'SampleRate15Mins',
}) async {
if (_isSimulation) return _simulatePowerBalance(from, to, sampleRate);
try {
final r = await _sendRequest('Energy.GetPowerBalanceLogs', {
'sampleRate': sampleRate,
'from': from.millisecondsSinceEpoch ~/ 1000,
'to': to.millisecondsSinceEpoch ~/ 1000,
});
final logs =
(r['params']?['powerBalanceLogEntries'] ?? r['powerBalanceLogEntries'])
as List? ?? [];
return logs.map<PowerBalanceEntry>((e) {
final m = e as Map<String, dynamic>;
double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0;
return PowerBalanceEntry(
timestamp: DateTime.fromMillisecondsSinceEpoch(
(m['timestamp'] as num).toInt() * 1000),
// production est négative dans nymea valeur absolue
productionW: d('production').abs(),
consumptionW: d('consumption'),
acquisitionW: d('acquisition'),
storageW: d('storage'),
totalProductionWh: d('totalProduction'),
totalConsumptionWh:d('totalConsumption'),
totalReturnWh: d('totalReturn'),
totalAcquisitionWh:d('totalAcquisition'),
);
}).toList();
} catch (e) {
_log('fetchPowerBalanceLogs: $e');
return [];
}
}
List<PowerBalanceEntry> _simulatePowerBalance(
DateTime from, DateTime to, String sampleRate) {
final entries = <PowerBalanceEntry>[];
const steps = 48;
final stepDur = to.difference(from) ~/ steps;
final stepHours = stepDur.inMinutes / 60.0;
double totalProd = 50000, totalConso = 80000,
totalRet = 20000, totalAcq = 50000;
for (int i = 0; i <= steps; i++) {
final t = from.add(stepDur * i);
// Courbe solaire en demi-sinus sur la journée (pic à midi)
final hFrac = (t.hour + t.minute / 60.0) / 24.0;
final prod = (2200 * sin(hFrac * pi)).clamp(0.0, 2200).toDouble();
final conso = 600 + 400 * sin(i * pi / 10 + 1.0);
final excess = prod - conso;
final storage = excess.clamp(-300.0, 300.0).toDouble();
final acq = (conso - prod - storage).clamp(0.0, double.infinity).toDouble();
final ret = (excess - storage).clamp(0.0, double.infinity).toDouble();
totalProd += prod * stepHours;
totalConso += conso * stepHours;
totalRet += ret * stepHours;
totalAcq += acq * stepHours;
entries.add(PowerBalanceEntry(
timestamp: t,
productionW: prod,
consumptionW: conso,
acquisitionW: acq,
storageW: storage,
totalProductionWh: totalProd,
totalConsumptionWh: totalConso,
totalReturnWh: totalRet,
totalAcquisitionWh: totalAcq,
));
}
return entries;
}
/// Rafraîchit things + classes + énergie
Future<void> refresh() async {
await Future.wait([_loadThings(), _loadEnergyStatus()]);
@ -785,22 +866,129 @@ class NymeaService extends ChangeNotifier {
}
}
Future<bool> executeAction({
/// Exécute une action sur un thing.
/// Retourne un [NymeaActionResult] avec le thingError et un message humain.
Future<NymeaActionResult> executeAction({
required String thingId,
required String actionTypeId,
Map<String, dynamic> params = const {},
}) async {
if (_isSimulation) return true;
if (_isSimulation) {
return const NymeaActionResult(success: true);
}
try {
final r = await _sendRequest('Integrations.ExecuteAction', {
'thingId': thingId,
'actionTypeId': actionTypeId,
// Format nymea : liste [{paramTypeId, value}]
'params': params.entries
.map((e) => {'paramTypeId': e.key, 'value': e.value})
.toList(),
});
return r['status'] == 'success';
} catch (e) { _log('ExecuteAction: $e'); return false; }
final thingError =
r['params']?['thingError'] as String? ?? 'ThingErrorNoError';
final displayMessage = r['params']?['displayMessage'] as String?;
final success = r['status'] == 'success' &&
(thingError.isEmpty || thingError == 'ThingErrorNoError');
return NymeaActionResult(
success: success,
thingError: thingError,
displayMessage: displayMessage,
);
} catch (e) {
_log('ExecuteAction: $e');
return NymeaActionResult(
success: false,
thingError: 'ThingErrorHardwareNotAvailable',
displayMessage: e.toString(),
);
}
}
/// Modifie directement la valeur d'un état writable via Integrations.SetStateValue.
/// Certains integrateurs nymea supportent cette méthode (nymea 1.10).
Future<bool> setStateValue(
String thingId, String stateTypeId, dynamic value) async {
if (_isSimulation) {
// Mise à jour locale pour la simulation
final idx = _things.indexWhere((t) => t.id == thingId);
if (idx >= 0) {
final states = List<NymeaStateValue>.from(_things[idx].states);
final si = states.indexWhere((s) => s.stateTypeId == stateTypeId);
if (si >= 0) {
states[si] = NymeaStateValue(stateTypeId: stateTypeId, value: value);
} else {
states.add(NymeaStateValue(stateTypeId: stateTypeId, value: value));
}
_things[idx] = _things[idx].copyWith(states: states);
notifyListeners();
}
return true;
}
try {
final r = await _sendRequest('Integrations.SetStateValue', {
'thingId': thingId,
'stateTypeId': stateTypeId,
'value': value,
});
final thingError =
r['params']?['thingError'] as String? ?? 'ThingErrorNoError';
return r['status'] == 'success' &&
(thingError.isEmpty || thingError == 'ThingErrorNoError');
} catch (e) {
_log('SetStateValue: $e');
return false;
}
}
/// Récupère l'historique d'un état via Logging.GetLogEntries.
///
/// [stateTypeName] = nom de l'état (ex: "currentPower", "temperature").
/// [sampleRate] = "SampleRate1Min" | "SampleRate15Mins" | "SampleRate1Hour" |
/// "SampleRate3Hours" | "SampleRate1Day".
/// Timestamps [from] et [to] convertis en ms côté protocole.
Future<List<HistoryEntry>> fetchHistory({
required String thingId,
required String stateTypeName,
required DateTime from,
required DateTime to,
String sampleRate = 'SampleRate15Mins',
}) async {
if (_isSimulation) {
// Données sinusoïdales simulées
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));
}
return entries;
}
try {
// Source nymea : "state-{thingId}-{stateTypeName}"
final source = 'state-$thingId-$stateTypeName';
final r = await _sendRequest('Logging.GetLogEntries', {
'sources': [source],
'startTime': from.millisecondsSinceEpoch,
'endTime': to.millisecondsSinceEpoch,
'sampleRate': sampleRate,
'sortOrder': 'Qt::AscendingOrder',
});
final logEntries =
(r['params']?['logEntries'] ?? r['logEntries']) as List? ?? [];
return logEntries.map<HistoryEntry>((e) {
final ts = DateTime.fromMillisecondsSinceEpoch(
(e['timestamp'] as num).toInt());
final values = (e['values'] as Map?)?.cast<String, dynamic>() ?? {};
final v = (values[stateTypeName] as num?)?.toDouble() ?? 0.0;
return HistoryEntry(timestamp: ts, value: v);
}).toList();
} catch (e) {
_log('fetchHistory: $e');
return [];
}
}
Future<dynamic> getStateValue(String thingId, String stateTypeId) async {

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
class AppTheme {
// Colors
// Couleurs historiques (dashboard, widgets existants)
static const Color primaryGreen = Color(0xFF4CAF50);
static const Color solarYellow = Color(0xFFF5C842);
static const Color gridGray = Color(0xFF5A6473);
@ -16,9 +16,41 @@ class AppTheme {
static const Color pvGreen = Color(0xFF43A047);
static const Color minPvBlue = Color(0xFF42A5F5);
// Couleurs nymea-style
/// Couleur d'accent principale nymea (teal/turquoise — #57baae de StyleBase.qml)
static const Color accentTeal = Color(0xFF57BAAE);
/// Fond de tuile nymea légèrement teinté (tileBackgroundColor dans StyleBase.qml)
static const Color tileBackground = Color(0xFFF2F4F7);
/// Rayon d'arrondi nymea standard (cornerRadius: 10 dans StyleBase.qml)
static const double cornerRadius = 10.0;
// Couleurs flux énergie (miroir de StyleBase.qml)
/// Soutirer du réseau (grid import) indianred
static const Color powerAcquisitionColor = Color(0xFFCD5C5C);
/// Injection sur le réseau (grid export) yellow
static const Color powerReturnColor = Color(0xFFCDCD5C);
/// Consommation maison blue
static const Color powerConsumptionColor = Color(0xFF5C95CD);
/// PV maison (autoconsommation) green
static const Color powerSelfProductionColor = Color(0xFF5CCD5C);
/// Charge batterie purple
static const Color powerBatteryChargingColor = Color(0xFF955CCD);
/// Décharge batterie orange
static const Color powerBatteryDischargingColor = Color(0xFFCD955C);
// Thème Material3
static ThemeData get theme => ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: primaryGreen,
seedColor: accentTeal,
surface: backgroundGray,
),
scaffoldBackgroundColor: backgroundGray,

View File

@ -19,12 +19,15 @@ class EnergyFlowWidget extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Données en temps réel',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
const Expanded(
child: Text(
'Données en temps réel',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
overflow: TextOverflow.ellipsis,
),
),
Row(
@ -116,13 +119,13 @@ class _EnergyNode extends StatelessWidget {
clipBehavior: Clip.none,
children: [
Container(
width: 64,
height: 64,
width: 52,
height: 52,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 30),
child: Icon(icon, color: Colors.white, size: 24),
),
if (badge != null)
Positioned(

244
lib/widgets/nymea_tile.dart Normal file
View File

@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
//
// NymeaTile tuile carrée inspirée de MainPageTile.qml (nymea-app)
//
// Caractéristiques :
// Fond tileBackground (légèrement teinté, comme nymea)
// Effet glow animé sur press (BoxShadow colorée simule Glow{} de Qt)
// Icône + pastille de statut en haut
// Valeur principale (grande, colorée) au bas
// Nom du thing tout en bas
//
class NymeaTile extends StatefulWidget {
/// Widget icône affiché en haut à gauche (généralement un [_TileIcon])
final Widget iconWidget;
/// Nom principal affiché en bas de la tuile
final String title;
/// Valeur d'état primaire (ex. "3.47 kW", "21.5 °C", "On")
final String? primaryValue;
/// Sous-titre optionnel (ex. nom de classe)
final String? subtitle;
/// L'appareil est-il en ligne ? Influence couleur icône et glow
final bool isOnline;
/// Couleur d'accent de la catégorie (utilisée pour l'icône + glow + valeur)
final Color accentColor;
/// Widget optionnel en haut à droite (sinon : pastille de statut)
final Widget? trailing;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const NymeaTile({
super.key,
required this.iconWidget,
required this.title,
this.primaryValue,
this.subtitle,
this.isOnline = true,
this.accentColor = AppTheme.accentTeal,
this.trailing,
this.onTap,
this.onLongPress,
});
@override
State<NymeaTile> createState() => _NymeaTileState();
}
class _NymeaTileState extends State<NymeaTile>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _glow;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
);
_glow = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails _) => _ctrl.forward();
void _onTapUp(TapUpDetails _) {
_ctrl.reverse();
widget.onTap?.call();
}
void _onTapCancel() => _ctrl.reverse();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onLongPress: widget.onLongPress,
child: AnimatedBuilder(
animation: _glow,
builder: (_, child) => Container(
decoration: BoxDecoration(
color: AppTheme.tileBackground,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
boxShadow: [
// Ombre de base (toujours présente)
BoxShadow(
color: Colors.black.withValues(alpha: 0.07),
blurRadius: 6,
offset: const Offset(0, 2),
),
// Glow coloré sur press (simule Glow{} de nymea-app)
BoxShadow(
color: widget.accentColor
.withValues(alpha: 0.45 * _glow.value),
blurRadius: 14 * _glow.value,
spreadRadius: 2 * _glow.value,
),
],
),
child: child,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header : icône + pastille statut
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.iconWidget,
widget.trailing ??
_StatusDot(
isOnline: widget.isOnline,
color: widget.accentColor,
),
],
),
const Spacer(),
// Valeur principale
if (widget.primaryValue != null)
Text(
widget.primaryValue!,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: widget.isOnline
? widget.accentColor
: AppTheme.textLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Nom du thing
Text(
widget.title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textDark,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// Sous-titre (classe)
if (widget.subtitle != null)
Text(
widget.subtitle!,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
// Pastille de statut
class _StatusDot extends StatelessWidget {
final bool isOnline;
final Color color;
const _StatusDot({required this.isOnline, required this.color});
@override
Widget build(BuildContext context) => Container(
width: 9,
height: 9,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
color: isOnline ? color : Colors.grey[400],
shape: BoxShape.circle,
boxShadow: isOnline
? [
BoxShadow(
color: color.withValues(alpha: 0.5),
blurRadius: 4,
spreadRadius: 1,
)
]
: null,
),
);
}
// TileIcon icône dans container arrondi (helper)
/// Icône standard pour une NymeaTile.
/// Usage : `TileIcon(icon: Icons.wb_sunny, color: AppTheme.solarYellow)`
class TileIcon extends StatelessWidget {
final IconData icon;
final Color color;
final bool isOnline;
const TileIcon({
super.key,
required this.icon,
required this.color,
this.isOnline = true,
});
@override
Widget build(BuildContext context) => Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: isOnline ? 0.15 : 0.07),
borderRadius: BorderRadius.circular(9),
),
child: Icon(
icon,
color: isOnline ? color : color.withValues(alpha: 0.4),
size: 22,
),
);
}

View File

@ -20,27 +20,31 @@ class ProductionCard extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Production du jour',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Production du jour',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
),
Text(
'${_formatEnergy(data.dayProductionWh)} Wh',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
Text(
'${_formatEnergy(data.dayProductionWh)} Wh',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
const SizedBox(width: 8),
CircularPercentIndicator(
radius: 35,
lineWidth: 6,

View File

@ -0,0 +1,280 @@
import 'dart:math' show min, max;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/nymea_models.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
//
// StateHistoryChart graphique historique d'un état nymea
//
// Utilise Logging.GetLogEntries (source: "state-{thingId}-{stateTypeName}")
// Sélecteur de plage : 1H / 24H / 7J / 30J
//
class StateHistoryChart extends StatefulWidget {
final String thingId;
final NymeaStateType stateType;
final Color accentColor;
const StateHistoryChart({
super.key,
required this.thingId,
required this.stateType,
required this.accentColor,
});
@override
State<StateHistoryChart> createState() => _StateHistoryChartState();
}
class _StateHistoryChartState extends State<StateHistoryChart> {
// Plages disponibles
static const _ranges = [
_Range('1H', Duration(hours: 1), 'SampleRate1Min'),
_Range('24H', Duration(hours: 24), 'SampleRate15Mins'),
_Range('7J', Duration(days: 7), 'SampleRate1Hour'),
_Range('30J', Duration(days: 30), 'SampleRate3Hours'),
];
int _rangeIdx = 1; // 24H par défaut
List<HistoryEntry> _data = [];
bool _loading = false;
bool _noData = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch());
}
Future<void> _fetch() async {
if (!mounted || _loading) return;
setState(() {
_loading = true;
_noData = false;
});
final range = _ranges[_rangeIdx];
final now = DateTime.now();
final from = now.subtract(range.duration);
final service = context.read<NymeaService>();
final data = await service.fetchHistory(
thingId: widget.thingId,
stateTypeName: widget.stateType.name,
from: from,
to: now,
sampleRate: range.sampleRate,
);
if (!mounted) return;
setState(() {
_data = data;
_loading = false;
_noData = data.isEmpty;
});
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
decoration: BoxDecoration(
color: AppTheme.cardWhite,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête : nom + sélecteur de plage
Row(
children: [
Expanded(
child: Text(
widget.stateType.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textDark,
),
overflow: TextOverflow.ellipsis,
),
),
for (int i = 0; i < _ranges.length; i++)
GestureDetector(
onTap: () {
if (_rangeIdx != i) {
setState(() => _rangeIdx = i);
_fetch();
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding:
const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: _rangeIdx == i
? widget.accentColor
: widget.accentColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_ranges[i].label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: _rangeIdx == i
? Colors.white
: widget.accentColor,
),
),
),
),
],
),
const SizedBox(height: 12),
// Zone du graphique
SizedBox(
height: 130,
child: _loading
? Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: widget.accentColor,
),
),
)
: _noData
? const Center(
child: Text(
'Aucune donnée historique',
style: TextStyle(
color: AppTheme.textLight, fontSize: 12),
),
)
: _buildChart(),
),
],
),
);
}
Widget _buildChart() {
if (_data.isEmpty) return const SizedBox.shrink();
// Spots : x = index flottant, y = valeur de l'état
final spots = <FlSpot>[];
for (int i = 0; i < _data.length; i++) {
spots.add(FlSpot(i.toDouble(), _data[i].value));
}
final minY = _data.map((e) => e.value).reduce(min);
final maxY = _data.map((e) => e.value).reduce(max);
final yPad = (maxY - minY) > 0 ? (maxY - minY) * 0.12 : 1.0;
final yInterval = (maxY - minY) > 0 ? (maxY - minY) / 3 : 1.0;
// Interval de l'axe X : afficher ~4 labels
final xInterval =
spots.length > 4 ? (spots.length / 4).ceilToDouble() : 1.0;
final showDate = _rangeIdx >= 2; // 7J ou 30J afficher jour/mois
return LineChart(
LineChartData(
clipData: const FlClipData.all(),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.25,
color: widget.accentColor,
barWidth: 2,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: widget.accentColor.withValues(alpha: 0.08),
),
),
],
minY: minY - yPad,
maxY: maxY + yPad,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yInterval,
getDrawingHorizontalLine: (_) => const FlLine(
color: Color(0xFFEEEEEE),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
// Axe Y
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 46,
interval: yInterval,
getTitlesWidget: (v, meta) {
// Formater sans répéter l'unité à chaque label
final raw = widget.stateType.formatValue(v);
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
raw,
style: const TextStyle(
fontSize: 8.5, color: AppTheme.textLight),
textAlign: TextAlign.right,
),
);
},
),
),
// Axe X
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 18,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.round();
if (idx < 0 || idx >= _data.length) {
return const SizedBox.shrink();
}
final t = _data[idx].timestamp;
final label = showDate
? '${t.day}/${t.month}'
: '${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}';
return Text(
label,
style: const TextStyle(
fontSize: 8.5, color: AppTheme.textLight),
);
},
),
),
),
),
);
}
}
// Plage de temps + sampleRate associé
class _Range {
final String label;
final Duration duration;
final String sampleRate;
const _Range(this.label, this.duration, this.sampleRate);
}