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.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) ──────────────────────────────
|
||||
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});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
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: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 où la liste shrink entre deux ticks du timer
|
||||
final safeIdx = _idx.clamp(0, widget.things.length - 1);
|
||||
final current = widget.things[safeIdx];
|
||||
final cls = _cls(current);
|
||||
final online = _isOnline(current, cls);
|
||||
final value = _primaryValue(current, cls);
|
||||
final info = widget.info;
|
||||
|
||||
// ── 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
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(
|
||||
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,
|
||||
|
||||
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