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:
parent
d5dc0c7ca5
commit
8862dc2a72
@ -98,4 +98,35 @@ class HistoryPoint {
|
|||||||
this.gridWh = 0,
|
this.gridWh = 0,
|
||||||
this.batteryWh = 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();
|
||||||
}
|
}
|
||||||
@ -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) ──────────────────────────────
|
// ── State type definition (from GetThingClasses) ──────────────────────────────
|
||||||
class NymeaStateType {
|
class NymeaStateType {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String type; // "Double", "Bool", "Int", "String"
|
final String type; // "Double", "Bool", "Int", "String"
|
||||||
final String unit; // "W", "V", "A", "Percentage", ""
|
final String unit; // "W", "V", "A", "Percentage", ""
|
||||||
final dynamic defaultValue;
|
final dynamic defaultValue;
|
||||||
final bool writable;
|
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({
|
const NymeaStateType({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -89,6 +123,9 @@ class NymeaStateType {
|
|||||||
required this.unit,
|
required this.unit,
|
||||||
this.defaultValue,
|
this.defaultValue,
|
||||||
this.writable = false,
|
this.writable = false,
|
||||||
|
this.allowedValues,
|
||||||
|
this.minValue,
|
||||||
|
this.maxValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory NymeaStateType.fromJson(Map<String, dynamic> j) => NymeaStateType(
|
factory NymeaStateType.fromJson(Map<String, dynamic> j) => NymeaStateType(
|
||||||
@ -99,6 +136,10 @@ class NymeaStateType {
|
|||||||
unit: j['unit'] as String? ?? '',
|
unit: j['unit'] as String? ?? '',
|
||||||
defaultValue: j['defaultValue'],
|
defaultValue: j['defaultValue'],
|
||||||
writable: j['writable'] as bool? ?? false,
|
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) {
|
String formatValue(dynamic value) {
|
||||||
@ -123,11 +164,7 @@ class NymeaStateType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _fmt(dynamic value) {
|
String _fmt(dynamic value) {
|
||||||
if (value is double) {
|
if (value is double) return value.toStringAsFixed(2);
|
||||||
if (value.abs() >= 1000) return value.toStringAsFixed(0);
|
|
||||||
if (value.abs() >= 10) return value.toStringAsFixed(1);
|
|
||||||
return value.toStringAsFixed(2);
|
|
||||||
}
|
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,3 +282,11 @@ class FavoriteWidget {
|
|||||||
this.extra = const {},
|
this.extra = const {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Historique d'un état ───────────────────────────────────────────────────────
|
||||||
|
class HistoryEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final double value;
|
||||||
|
|
||||||
|
const HistoryEntry({required this.timestamp, required this.value});
|
||||||
|
}
|
||||||
|
|||||||
@ -10,7 +10,8 @@ enum ThingCategory {
|
|||||||
energy, // smartmeter, energymeter, powersocket
|
energy, // smartmeter, energymeter, powersocket
|
||||||
solar, // solarinverter, solarpanel
|
solar, // solarinverter, solarpanel
|
||||||
battery, // battery, energystorage
|
battery, // battery, energystorage
|
||||||
evCharger, // evcharger
|
evCharger, // evcharger, wallbox
|
||||||
|
cars, // evvehicle, electricvehicle + class name "car"/"vehicle"
|
||||||
hvac, // thermostat, heatingzone, ac, pump
|
hvac, // thermostat, heatingzone, ac, pump
|
||||||
lighting, // light, dimmablelight, colorlight
|
lighting, // light, dimmablelight, colorlight
|
||||||
sensors, // sensor, temperaturesensor, humiditysensor, motionsensor
|
sensors, // sensor, temperaturesensor, humiditysensor, motionsensor
|
||||||
@ -55,6 +56,10 @@ const Map<String, ThingCategory> interfaceToCategoryMap = {
|
|||||||
'batterymonitor': ThingCategory.battery,
|
'batterymonitor': ThingCategory.battery,
|
||||||
'evcharger': ThingCategory.evCharger,
|
'evcharger': ThingCategory.evCharger,
|
||||||
'wallbox': ThingCategory.evCharger,
|
'wallbox': ThingCategory.evCharger,
|
||||||
|
'evvehicle': ThingCategory.cars,
|
||||||
|
'electricvehicle': ThingCategory.cars,
|
||||||
|
'electriccar': ThingCategory.cars,
|
||||||
|
'vehicle': ThingCategory.cars,
|
||||||
'thermostat': ThingCategory.hvac,
|
'thermostat': ThingCategory.hvac,
|
||||||
'heatingzone': ThingCategory.hvac,
|
'heatingzone': ThingCategory.hvac,
|
||||||
'ac': ThingCategory.hvac,
|
'ac': ThingCategory.hvac,
|
||||||
@ -120,6 +125,12 @@ const Map<ThingCategory, ThingCategoryInfo> categoryInfoMap = {
|
|||||||
icon: Icons.electric_car_rounded,
|
icon: Icons.electric_car_rounded,
|
||||||
color: AppTheme.minPvBlue,
|
color: AppTheme.minPvBlue,
|
||||||
),
|
),
|
||||||
|
ThingCategory.cars: ThingCategoryInfo(
|
||||||
|
category: ThingCategory.cars,
|
||||||
|
label: 'Cars',
|
||||||
|
icon: Icons.directions_car_rounded,
|
||||||
|
color: AppTheme.accentTeal,
|
||||||
|
),
|
||||||
ThingCategory.hvac: ThingCategoryInfo(
|
ThingCategory.hvac: ThingCategoryInfo(
|
||||||
category: ThingCategory.hvac,
|
category: ThingCategory.hvac,
|
||||||
label: 'Chauffage & Climatisation',
|
label: 'Chauffage & Climatisation',
|
||||||
@ -180,6 +191,11 @@ extension ThingClassCategoryExt on NymeaThingClass {
|
|||||||
final cat = interfaceToCategoryMap[iface.toLowerCase()];
|
final cat = interfaceToCategoryMap[iface.toLowerCase()];
|
||||||
if (cat != null) return cat;
|
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;
|
return ThingCategory.other;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
272
lib/screens/category_overview_screen.dart
Normal file
272
lib/screens/category_overview_screen.dart
Normal 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
@ -1,82 +1,88 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/nymea_models.dart';
|
import '../models/nymea_models.dart';
|
||||||
import '../models/thing_category.dart';
|
import '../models/thing_category.dart';
|
||||||
import '../services/nymea_service.dart';
|
import '../services/nymea_service.dart';
|
||||||
import '../theme/app_theme.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});
|
const ThingsScreen({super.key});
|
||||||
@override
|
|
||||||
State<ThingsScreen> createState() => _ThingsScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ThingsScreenState extends State<ThingsScreen> {
|
static const _orderedCats = [
|
||||||
final Set<ThingCategory> _collapsed = {};
|
ThingCategory.energy, ThingCategory.solar, ThingCategory.battery,
|
||||||
String _searchQuery = '';
|
ThingCategory.evCharger, ThingCategory.cars, ThingCategory.hvac,
|
||||||
bool _showSearch = false;
|
ThingCategory.lighting, ThingCategory.sensors, ThingCategory.network,
|
||||||
|
ThingCategory.notifications, ThingCategory.weather, ThingCategory.media,
|
||||||
|
ThingCategory.other,
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final service = context.watch<NymeaService>();
|
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 things = service.things.where((t) => t.id.isNotEmpty).toList();
|
||||||
final thingClasses = service.thingClasses;
|
final classes = service.thingClasses;
|
||||||
|
|
||||||
final filtered = _searchQuery.isEmpty
|
|
||||||
? things
|
|
||||||
: things.where((t) =>
|
|
||||||
t.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
|
|
||||||
|
|
||||||
|
// Groupement par catégorie
|
||||||
final Map<ThingCategory, List<NymeaThing>> grouped = {};
|
final Map<ThingCategory, List<NymeaThing>> grouped = {};
|
||||||
for (final thing in filtered) {
|
for (final t in things) {
|
||||||
final cls = _classFor(thing, thingClasses);
|
final cls = _classFor(t, classes);
|
||||||
final cat = cls?.category ?? ThingCategory.other;
|
final cat = cls?.category ?? ThingCategory.other;
|
||||||
grouped.putIfAbsent(cat, () => []).add(thing);
|
grouped.putIfAbsent(cat, () => []).add(t);
|
||||||
}
|
}
|
||||||
|
final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList();
|
||||||
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();
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.backgroundGray,
|
backgroundColor: AppTheme.backgroundGray,
|
||||||
appBar: _buildAppBar(service, things.length),
|
appBar: _buildAppBar(service, things.length),
|
||||||
body: !service.isConnected
|
body: !service.isConnected
|
||||||
? _buildDisconnected()
|
? _buildPlaceholder(
|
||||||
: (things.isEmpty && !service.thingsLoaded)
|
icon: Icons.cloud_off_rounded,
|
||||||
? _buildLoading()
|
label: 'Non connecté à nymea',
|
||||||
|
)
|
||||||
|
: things.isEmpty && !service.thingsLoaded
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppTheme.accentTeal))
|
||||||
: things.isEmpty
|
: things.isEmpty
|
||||||
? _buildEmpty()
|
? _buildPlaceholder(
|
||||||
: cats.isEmpty
|
icon: Icons.devices_other_rounded,
|
||||||
// Classes pas encore chargées — afficher things en mode simple
|
label: 'Aucun appareil',
|
||||||
? _buildThingsSimple(things, service)
|
)
|
||||||
: ListView.builder(
|
: GridView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||||
itemCount: cats.length,
|
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) {
|
itemBuilder: (context, i) {
|
||||||
final cat = cats[i];
|
final cat = cats.isEmpty ? ThingCategory.other : cats[i];
|
||||||
return _CategorySection(
|
final catThings = grouped[cat] ?? things;
|
||||||
info: categoryInfoMap[cat]!,
|
final info = categoryInfoMap[cat]!;
|
||||||
things: grouped[cat]!,
|
return _CategoryTile(
|
||||||
thingClasses: thingClasses,
|
info: info,
|
||||||
collapsed: _collapsed.contains(cat),
|
things: catThings,
|
||||||
onToggle: () => setState(() {
|
thingClasses: classes,
|
||||||
if (_collapsed.contains(cat)) {
|
onTap: () => Navigator.push(
|
||||||
_collapsed.remove(cat);
|
|
||||||
} else {
|
|
||||||
_collapsed.add(cat);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
onTap: (t) => Navigator.push(
|
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ThingDetailScreen(thing: t)),
|
builder: (_) => CategoryOverviewScreen(
|
||||||
|
info: info,
|
||||||
|
things: catThings,
|
||||||
|
thingClasses: classes,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -88,46 +94,27 @@ class _ThingsScreenState extends State<ThingsScreen> {
|
|||||||
return AppBar(
|
return AppBar(
|
||||||
backgroundColor: AppTheme.backgroundGray,
|
backgroundColor: AppTheme.backgroundGray,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: _showSearch
|
title: Row(children: [
|
||||||
? TextField(
|
const Text('Things',
|
||||||
autofocus: true,
|
style: TextStyle(
|
||||||
decoration: const InputDecoration(
|
fontWeight: FontWeight.bold,
|
||||||
hintText: 'Rechercher un appareil...',
|
color: AppTheme.textDark,
|
||||||
border: InputBorder.none,
|
fontSize: 20)),
|
||||||
hintStyle: TextStyle(color: AppTheme.textLight),
|
const SizedBox(width: 8),
|
||||||
),
|
Container(
|
||||||
style: const TextStyle(color: AppTheme.textDark, fontSize: 18),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
onChanged: (v) => setState(() => _searchQuery = v),
|
decoration: BoxDecoration(
|
||||||
)
|
color: AppTheme.accentTeal.withValues(alpha: 0.15),
|
||||||
: Row(children: [
|
borderRadius: BorderRadius.circular(10),
|
||||||
const Text('Things',
|
),
|
||||||
style: TextStyle(
|
child: Text('$count',
|
||||||
fontWeight: FontWeight.bold,
|
style: const TextStyle(
|
||||||
color: AppTheme.textDark,
|
fontSize: 12,
|
||||||
fontSize: 20)),
|
fontWeight: FontWeight.bold,
|
||||||
const SizedBox(width: 8),
|
color: AppTheme.accentTeal)),
|
||||||
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 = '';
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark),
|
icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark),
|
||||||
onPressed: () => service.refresh(),
|
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: [
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
CircularProgressIndicator(color: AppTheme.primaryGreen),
|
Icon(icon, size: 64, color: Colors.grey[400]),
|
||||||
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]),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('Non connecté à nymea',
|
Text(label,
|
||||||
style: TextStyle(color: AppTheme.textLight, fontSize: 16)),
|
style:
|
||||||
|
const TextStyle(color: AppTheme.textLight, fontSize: 16)),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Affichage simple quand les ThingClasses ne sont pas encore chargées
|
static NymeaThingClass? _classFor(
|
||||||
Widget _buildThingsSimple(List<NymeaThing> things, NymeaService service) {
|
NymeaThing t, List<NymeaThingClass> classes) {
|
||||||
return ListView.builder(
|
try {
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
return classes.firstWhere((c) => c.id == t.thingClassId);
|
||||||
itemCount: things.length,
|
} catch (_) {
|
||||||
itemBuilder: (context, i) {
|
return null;
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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 ThingCategoryInfo info;
|
||||||
final List<NymeaThing> things;
|
final List<NymeaThing> things;
|
||||||
final List<NymeaThingClass> thingClasses;
|
final List<NymeaThingClass> thingClasses;
|
||||||
final bool collapsed;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onToggle;
|
|
||||||
final void Function(NymeaThing) onTap;
|
|
||||||
|
|
||||||
const _CategorySection({
|
const _CategoryTile({
|
||||||
required this.info, required this.things, required this.thingClasses,
|
required this.info,
|
||||||
required this.collapsed, required this.onToggle, required this.onTap,
|
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) {
|
NymeaThingClass? _cls(NymeaThing t) {
|
||||||
try { return thingClasses.firstWhere((c) => c.id == t.thingClassId); }
|
try {
|
||||||
catch (_) { return null; }
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
// Clamp _idx au cas où la liste shrink entre deux ticks du timer
|
||||||
// ── Header collapsable ─────────────────────────────────────────────────
|
final safeIdx = _idx.clamp(0, widget.things.length - 1);
|
||||||
InkWell(
|
final current = widget.things[safeIdx];
|
||||||
onTap: onToggle,
|
final cls = _cls(current);
|
||||||
borderRadius: BorderRadius.circular(12),
|
final online = _isOnline(current, cls);
|
||||||
child: Padding(
|
final value = _primaryValue(current, cls);
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
final info = widget.info;
|
||||||
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),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Things ────────────────────────────────────────────────────────────
|
return Material(
|
||||||
AnimatedCrossFade(
|
color: AppTheme.cardWhite,
|
||||||
duration: const Duration(milliseconds: 220),
|
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
|
||||||
crossFadeState: collapsed
|
child: InkWell(
|
||||||
? CrossFadeState.showSecond
|
onTap: widget.onTap,
|
||||||
: CrossFadeState.showFirst,
|
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
|
||||||
firstChild: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
...things.map((t) => Padding(
|
// ── Haut : icône + nom catégorie ───────────────────────────────
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
Expanded(
|
||||||
child: ThingCard(
|
child: Column(
|
||||||
thing: t,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
thingClass: _cls(t),
|
children: [
|
||||||
categoryInfo: info,
|
Container(
|
||||||
onTap: () => onTap(t),
|
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 {
|
class ThingCard extends StatelessWidget {
|
||||||
final NymeaThing thing;
|
final NymeaThing thing;
|
||||||
@ -312,285 +384,23 @@ class ThingCard extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const ThingCard({
|
const ThingCard({
|
||||||
super.key, required this.thing, required this.thingClass,
|
super.key,
|
||||||
required this.categoryInfo, required this.onTap,
|
required this.thing,
|
||||||
|
required this.thingClass,
|
||||||
|
required this.categoryInfo,
|
||||||
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final primaryType = thingClass?.primaryStateType;
|
final primary = thingClass?.primaryStateType;
|
||||||
final primaryValue = primaryType?.formatValue(thing.stateValue(primaryType.id));
|
final value = primary?.formatValue(thing.stateValue(primary.id));
|
||||||
final secondaryStates = _secondaryStates();
|
return ListTile(
|
||||||
final isOnline = _isOnline();
|
leading: Icon(categoryInfo.icon, color: categoryInfo.color),
|
||||||
|
title: Text(thing.name),
|
||||||
return Card(
|
trailing:
|
||||||
child: InkWell(
|
value != null ? Text(value, style: TextStyle(color: categoryInfo.color)) : null,
|
||||||
borderRadius: BorderRadius.circular(16),
|
onTap: onTap,
|
||||||
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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,7 @@ import 'dart:math';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.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';
|
import '../models/nymea_models.dart';
|
||||||
|
|
||||||
// ── Protocole de connexion ─────────────────────────────────────────────────────
|
// ── Protocole de connexion ─────────────────────────────────────────────────────
|
||||||
@ -558,6 +558,87 @@ class NymeaService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> refreshEnergy() => _loadEnergyStatus();
|
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
|
/// Rafraîchit things + classes + énergie
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
await Future.wait([_loadThings(), _loadEnergyStatus()]);
|
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 thingId,
|
||||||
required String actionTypeId,
|
required String actionTypeId,
|
||||||
Map<String, dynamic> params = const {},
|
Map<String, dynamic> params = const {},
|
||||||
}) async {
|
}) async {
|
||||||
if (_isSimulation) return true;
|
if (_isSimulation) {
|
||||||
|
return const NymeaActionResult(success: true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final r = await _sendRequest('Integrations.ExecuteAction', {
|
final r = await _sendRequest('Integrations.ExecuteAction', {
|
||||||
'thingId': thingId,
|
'thingId': thingId,
|
||||||
'actionTypeId': actionTypeId,
|
'actionTypeId': actionTypeId,
|
||||||
|
// Format nymea : liste [{paramTypeId, value}]
|
||||||
'params': params.entries
|
'params': params.entries
|
||||||
.map((e) => {'paramTypeId': e.key, 'value': e.value})
|
.map((e) => {'paramTypeId': e.key, 'value': e.value})
|
||||||
.toList(),
|
.toList(),
|
||||||
});
|
});
|
||||||
return r['status'] == 'success';
|
final thingError =
|
||||||
} catch (e) { _log('ExecuteAction: $e'); return false; }
|
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 {
|
Future<dynamic> getStateValue(String thingId, String stateTypeId) async {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
// Colors
|
// ── Couleurs historiques (dashboard, widgets existants) ──────────────────────
|
||||||
static const Color primaryGreen = Color(0xFF4CAF50);
|
static const Color primaryGreen = Color(0xFF4CAF50);
|
||||||
static const Color solarYellow = Color(0xFFF5C842);
|
static const Color solarYellow = Color(0xFFF5C842);
|
||||||
static const Color gridGray = Color(0xFF5A6473);
|
static const Color gridGray = Color(0xFF5A6473);
|
||||||
@ -16,9 +16,41 @@ class AppTheme {
|
|||||||
static const Color pvGreen = Color(0xFF43A047);
|
static const Color pvGreen = Color(0xFF43A047);
|
||||||
static const Color minPvBlue = Color(0xFF42A5F5);
|
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(
|
static ThemeData get theme => ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: primaryGreen,
|
seedColor: accentTeal,
|
||||||
surface: backgroundGray,
|
surface: backgroundGray,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: backgroundGray,
|
scaffoldBackgroundColor: backgroundGray,
|
||||||
|
|||||||
@ -19,12 +19,15 @@ class EnergyFlowWidget extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Expanded(
|
||||||
'Données en temps réel',
|
child: Text(
|
||||||
style: TextStyle(
|
'Données en temps réel',
|
||||||
fontSize: 17,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 17,
|
||||||
color: AppTheme.textDark,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textDark,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
@ -116,13 +119,13 @@ class _EnergyNode extends StatelessWidget {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 64,
|
width: 52,
|
||||||
height: 64,
|
height: 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: Colors.white, size: 30),
|
child: Icon(icon, color: Colors.white, size: 24),
|
||||||
),
|
),
|
||||||
if (badge != null)
|
if (badge != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
244
lib/widgets/nymea_tile.dart
Normal file
244
lib/widgets/nymea_tile.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,27 +20,31 @@ class ProductionCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Expanded(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Text(
|
children: [
|
||||||
'Production du jour',
|
const Text(
|
||||||
style: TextStyle(
|
'Production du jour',
|
||||||
fontSize: 16,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 16,
|
||||||
color: AppTheme.textDark,
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textDark,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
Text(
|
'${_formatEnergy(data.dayProductionWh)} Wh',
|
||||||
'${_formatEnergy(data.dayProductionWh)} Wh',
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
fontSize: 22,
|
||||||
fontSize: 22,
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
color: AppTheme.textDark,
|
||||||
color: AppTheme.textDark,
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
CircularPercentIndicator(
|
CircularPercentIndicator(
|
||||||
radius: 35,
|
radius: 35,
|
||||||
lineWidth: 6,
|
lineWidth: 6,
|
||||||
|
|||||||
280
lib/widgets/state_history_chart.dart
Normal file
280
lib/widgets/state_history_chart.dart
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user