diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index 89a41f7..8f38154 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -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(); } \ No newline at end of file diff --git a/lib/models/nymea_models.dart b/lib/models/nymea_models.dart index 6c36730..2f0a32f 100644 --- a/lib/models/nymea_models.dart +++ b/lib/models/nymea_models.dart @@ -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? 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 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}); +} diff --git a/lib/models/thing_category.dart b/lib/models/thing_category.dart index f7390c7..0645672 100644 --- a/lib/models/thing_category.dart +++ b/lib/models/thing_category.dart @@ -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 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 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; } diff --git a/lib/screens/category_overview_screen.dart b/lib/screens/category_overview_screen.dart new file mode 100644 index 0000000..726af37 --- /dev/null +++ b/lib/screens/category_overview_screen.dart @@ -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 things; + final List 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(); + 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), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 01e39fa..8afaf10 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -1,9 +1,30 @@ -import 'package:flutter/material.dart'; +import 'dart:math' show max, min; import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/energy_data.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; +// ───────────────────────────────────────────────────────────────────────────── +// EnergyScreen — historique énergétique +// +// ① Line chart : Production · Consommation · Autoconsommation · Batterie +// ② Bar chart : Bilan Production vs Consommation par période +// +// Onglets : Heures (24 h, 15 min) · Jour (7 j, 1 h) · +// Semaine (4 sem, 1 j) · Mois (12 mois, 1 sem) +// ───────────────────────────────────────────────────────────────────────────── + +class _Tab { + final String label; + final Duration range; + final String sampleRate; + final bool showTime; // true → HH:mm, false → DD/MM + + const _Tab(this.label, this.range, this.sampleRate, {required this.showTime}); +} + class EnergyScreen extends StatefulWidget { const EnergyScreen({super.key}); @@ -12,290 +33,192 @@ class EnergyScreen extends StatefulWidget { } class _EnergyScreenState extends State { - String _period = 'Jour'; - final List _periods = ['Jour', 'Semaine', 'Mois', 'Année']; + static const _tabs = [ + _Tab('Heures', Duration(hours: 24), 'SampleRate15Mins', showTime: true), + _Tab('Jour', Duration(days: 7), 'SampleRate1Hour', showTime: false), + _Tab('Semaine', Duration(days: 28), 'SampleRate1Day', showTime: false), + _Tab('Mois', Duration(days: 365), 'SampleRate1Week', showTime: false), + ]; + + int _tabIdx = 0; + List _data = []; + bool _loading = true; // true dès le départ → spinner jusqu'au premier fetch + bool _noData = false; + DateTime? _selectedDate; // null = aujourd'hui (date courante) + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetch()); + } + + Future _fetch() async { + if (!mounted) return; + setState(() { _loading = true; _noData = false; }); + final tab = _tabs[_tabIdx]; + // Ancre : fin de la journée sélectionnée, ou maintenant si aucune date + final to = _selectedDate != null + ? DateTime( + _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, + 23, 59, 59) + : DateTime.now(); + final data = await context.read().fetchPowerBalanceLogs( + from: to.subtract(tab.range), + to: to, + sampleRate: tab.sampleRate, + ); + if (!mounted) return; + setState(() { + _data = data; + _loading = false; + _noData = data.isEmpty; + }); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now(), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: const ColorScheme.light( + primary: AppTheme.accentTeal, + onPrimary: Colors.white, + ), + ), + child: child!, + ), + ); + if (picked != null && mounted) { + setState(() => _selectedDate = picked); + _fetch(); + } + } @override Widget build(BuildContext context) { return Consumer( builder: (context, service, _) { - final points = service.historyPoints; - return Scaffold( backgroundColor: AppTheme.backgroundGray, appBar: AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, - title: const Text( - 'Énergie', - style: TextStyle( - fontWeight: FontWeight.bold, color: AppTheme.textDark), - ), + title: const Text('Énergie', + style: TextStyle( + fontWeight: FontWeight.bold, color: AppTheme.textDark)), actions: [ IconButton( - icon: const Icon(Icons.file_download_outlined, + icon: Icon( + Icons.calendar_today_rounded, + color: _selectedDate != null + ? AppTheme.accentTeal + : AppTheme.textDark, + ), + onPressed: _pickDate, + ), + IconButton( + icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark), - onPressed: () {}, + onPressed: _fetch, ), ], ), body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 32), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Period selector - Card( - child: Padding( - padding: const EdgeInsets.all(4), - child: Row( - children: _periods.map((p) { - final selected = _period == p; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _period = p), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: selected - ? AppTheme.primaryGreen - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - p, - textAlign: TextAlign.center, - style: TextStyle( - color: selected - ? Colors.white - : AppTheme.textLight, - fontWeight: selected - ? FontWeight.bold - : FontWeight.normal, - fontSize: 13, - ), - ), - ), - ), - ); - }).toList(), - ), - ), - ), - const SizedBox(height: 12), - - // Summary cards + // ── Tuiles résumé ─────────────────────────────────────────── _SummaryRow(service: service), - const SizedBox(height: 12), - - // Production chart - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Production & Consommation', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 15)), - const SizedBox(height: 4), - const _Legend(), - const SizedBox(height: 16), - SizedBox( - height: 200, - child: points.isEmpty - ? const Center(child: CircularProgressIndicator()) - : BarChart( - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: 5000, - barTouchData: BarTouchData(enabled: true), - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 24, - getTitlesWidget: (value, meta) { - final idx = value.toInt(); - if (idx % 3 != 0 || - idx >= points.length) { - return const SizedBox(); - } - return Text( - '${points[idx].time.hour}h', - style: const TextStyle( - fontSize: 10, - color: AppTheme.textLight), - ); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) => Text( - '${(value / 1000).toStringAsFixed(1)}k', - style: const TextStyle( - fontSize: 9, - color: AppTheme.textLight), - ), - ), - ), - rightTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false)), - topTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (value) => - FlLine( - color: Colors.grey.shade100, - strokeWidth: 1, - ), - ), - barGroups: points - .asMap() - .entries - .map( - (e) => BarChartGroupData( - x: e.key, - barsSpace: 2, - barRods: [ - BarChartRodData( - toY: e.value.pvWh, - color: AppTheme.solarYellow, - width: 5, - borderRadius: - const BorderRadius.vertical( - top: Radius.circular(3)), - ), - BarChartRodData( - toY: e.value.homeWh, - color: AppTheme.homeBlue, - width: 5, - borderRadius: - const BorderRadius.vertical( - top: Radius.circular(3)), - ), - ], - ), - ) - .toList(), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - - // Grid line chart - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Import/Export réseau', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 15)), - const SizedBox(height: 16), - SizedBox( - height: 160, - child: points.isEmpty - ? const Center(child: CircularProgressIndicator()) - : LineChart( - LineChartData( - minY: -2000, - maxY: 3000, - lineTouchData: - LineTouchData(enabled: true), - titlesData: FlTitlesData( - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 22, - getTitlesWidget: (value, meta) { - final idx = value.toInt(); - if (idx % 4 != 0 || - idx >= points.length) { - return const SizedBox(); - } - return Text( - '${points[idx].time.hour}h', - style: const TextStyle( - fontSize: 10, - color: AppTheme.textLight), - ); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 42, - getTitlesWidget: (v, _) => Text( - '${v.toStringAsFixed(0)} W', - style: const TextStyle( - fontSize: 9, - color: AppTheme.textLight), - ), - ), - ), - rightTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false)), - topTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - gridData: FlGridData( - drawVerticalLine: false, - getDrawingHorizontalLine: (v) => FlLine( - color: v == 0 - ? Colors.grey.shade400 - : Colors.grey.shade100, - strokeWidth: v == 0 ? 1.5 : 1), - ), - lineBarsData: [ - LineChartBarData( - spots: points - .asMap() - .entries - .map((e) => FlSpot( - e.key.toDouble(), - e.value.gridWh)) - .toList(), - isCurved: true, - color: Colors.orange, - barWidth: 2, - belowBarData: BarAreaData( - show: true, - color: Colors.orange.withValues(alpha:0.1), - ), - dotData: const FlDotData(show: false), - ), - ], - ), - ), - ), - ], - ), - ), - ), const SizedBox(height: 16), + + // ── Sélecteur d'onglet ────────────────────────────────────── + _buildTabBar(), + const SizedBox(height: 8), + + // ── Date sélectionnée (chip dismissible) ──────────────────── + if (_selectedDate != null) + Align( + alignment: Alignment.centerLeft, + child: InkWell( + onTap: () { + setState(() => _selectedDate = null); + _fetch(); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.accentTeal.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppTheme.accentTeal, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.calendar_today_rounded, + size: 12, color: AppTheme.accentTeal), + const SizedBox(width: 5), + Text( + '${_selectedDate!.day.toString().padLeft(2, '0')}/' + '${_selectedDate!.month.toString().padLeft(2, '0')}/' + '${_selectedDate!.year}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.accentTeal, + fontWeight: FontWeight.bold), + ), + const SizedBox(width: 5), + const Icon(Icons.close_rounded, + size: 12, color: AppTheme.accentTeal), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + + // ── ① Line chart ──────────────────────────────────────────── + _ChartCard( + title: 'Puissances (W)', + legend: const [ + _LegendItem(color: AppTheme.solarYellow, label: 'Production'), + _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), + _LegendItem(color: AppTheme.accentTeal, label: 'Autoconso'), + _LegendItem(color: AppTheme.batteryGreen, label: 'Batterie', dashed: true), + ], + child: SizedBox( + height: 200, + child: _loading + ? _spinner() + : _noData + ? _empty() + : _buildLineChart(), + ), + ), + const SizedBox(height: 12), + + // ── ② Bar chart ───────────────────────────────────────────── + _ChartCard( + title: 'Bilan énergétique (Wh)', + legend: const [ + _LegendItem(color: AppTheme.solarYellow, label: 'Production'), + _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), + ], + child: SizedBox( + height: 180, + child: _loading + ? _spinner() + : _noData + ? _empty() + : _buildBarChart(), + ), + ), ], ), ), @@ -303,33 +226,394 @@ class _EnergyScreenState extends State { }, ); } + + // ── Tab bar ─────────────────────────────────────────────────────────────── + + Widget _buildTabBar() { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppTheme.cardWhite, + borderRadius: BorderRadius.circular(AppTheme.cornerRadius), + ), + child: Row( + children: _tabs.asMap().entries.map((e) { + final sel = _tabIdx == e.key; + return Expanded( + child: GestureDetector( + onTap: () { + if (_tabIdx != e.key) { + setState(() => _tabIdx = e.key); + _fetch(); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: + sel ? AppTheme.accentTeal : Colors.transparent, + borderRadius: + BorderRadius.circular(AppTheme.cornerRadius - 2), + ), + child: Text( + e.value.label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: + sel ? FontWeight.bold : FontWeight.normal, + color: sel ? Colors.white : AppTheme.textLight, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + // ── ① Line chart ───────────────────────────────────────────────────────── + + Widget _buildLineChart() { + if (_data.isEmpty) return _empty(); + final prodSpots = []; + final consoSpots = []; + final autoSpots = []; + final batSpots = []; + + for (int i = 0; i < _data.length; i++) { + final d = _data[i]; + final x = i.toDouble(); + prodSpots .add(FlSpot(x, d.productionW)); + consoSpots.add(FlSpot(x, d.consumptionW)); + autoSpots .add(FlSpot(x, d.autoconsommationW)); + batSpots .add(FlSpot(x, d.storageW)); + } + + final allY = _data + .expand((d) => [d.productionW, d.consumptionW, d.storageW]) + .toList(); + final minY = allY.reduce(min); + final maxY = allY.reduce(max); + final spread = (maxY - minY) > 0 ? maxY - minY : 200.0; + final yPad = spread * 0.12; + + final xInterval = _xInterval(); + + return LineChart( + LineChartData( + clipData: const FlClipData.all(), + minY: minY - yPad, + maxY: maxY + yPad, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + 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)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, + getTitlesWidget: (v, _) => Padding( + padding: const EdgeInsets.only(right: 4), + child: Text(_fmtW(v), + style: const TextStyle( + fontSize: 8.5, color: AppTheme.textLight), + textAlign: TextAlign.right), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 18, + interval: xInterval, + getTitlesWidget: (v, _) { + final idx = v.round(); + if (idx < 0 || idx >= _data.length) { + return const SizedBox.shrink(); + } + return Text(_fmtTime(_data[idx].timestamp), + style: const TextStyle( + fontSize: 8.5, color: AppTheme.textLight)); + }, + ), + ), + ), + lineBarsData: [ + _lineSeries(prodSpots, AppTheme.solarYellow), + _lineSeries(consoSpots, AppTheme.homeBlue), + _lineSeries(autoSpots, AppTheme.accentTeal), + _lineSeries(batSpots, AppTheme.batteryGreen, dashed: true), + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) => spots.map((s) => LineTooltipItem( + _fmtW(s.y), + TextStyle( + color: s.bar.color ?? Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold), + )).toList(), + ), + ), + ), + ); + } + + LineChartBarData _lineSeries(List spots, Color color, + {bool dashed = false}) => + LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.2, + color: color, + barWidth: dashed ? 1.5 : 2, + dotData: const FlDotData(show: false), + dashArray: dashed ? [4, 4] : null, + belowBarData: BarAreaData( + show: !dashed, + color: color.withValues(alpha: 0.07), + ), + ); + + // ── ② Bar chart ────────────────────────────────────────────────────────── + + Widget _buildBarChart() { + // Énergie par période = delta des cumuls entre entrées successives + final groups = []; + double maxE = 1.0; + + // n barres (même étendue que le line chart x=0..n-1) + // barre[i] = énergie de la période [data[i-1] … data[i]] + // barre[0] = 0 (pas de période précédente) + for (int i = 0; i < _data.length; i++) { + final prodWh = i == 0 + ? 0.0 + : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh); + final consoWh = i == 0 + ? 0.0 + : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh); + maxE = [maxE, prodWh, consoWh].reduce(max); + final bw = _barWidth(); + groups.add(BarChartGroupData( + x: i, + barsSpace: 2, + barRods: [ + BarChartRodData( + toY: prodWh, + color: AppTheme.solarYellow, + width: bw, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + ), + BarChartRodData( + toY: consoWh, + color: AppTheme.homeBlue, + width: bw, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + ), + ], + )); + } + + return BarChart( + BarChartData( + maxY: maxE * 1.15, + alignment: BarChartAlignment.spaceAround, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIdx, rod, rodIdx) => BarTooltipItem( + _fmtWh(rod.toY), + TextStyle( + color: rod.color ?? Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold), + ), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + 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)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, + getTitlesWidget: (v, _) => Padding( + padding: const EdgeInsets.only(right: 4), + child: Text(_fmtWh(v), + style: const TextStyle( + fontSize: 8.5, color: AppTheme.textLight), + textAlign: TextAlign.right), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 18, + // Pas d'interval ici : fl_chart passe l'entier exact de chaque + // groupe → on filtre manuellement avec le même pas que le line chart + getTitlesWidget: (v, _) { + final idx = v.toInt(); + if (idx < 0 || idx >= _data.length) { + return const SizedBox.shrink(); + } + final step = _xInterval().toInt().clamp(1, _data.length); + if (idx % step != 0) return const SizedBox.shrink(); + return Text(_fmtTime(_data[idx].timestamp), + style: const TextStyle( + fontSize: 8.5, color: AppTheme.textLight)); + }, + ), + ), + ), + barGroups: groups, + ), + ); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + double _xInterval() => + _data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0; + + double _barWidth() => + _data.length > 40 ? 3 : _data.length > 20 ? 5 : 8; + + String _fmtW(double v) { + if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}kW'; + return '${v.toStringAsFixed(0)}W'; + } + + String _fmtWh(double v) { + if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}kWh'; + return '${v.toStringAsFixed(0)}Wh'; + } + + String _fmtTime(DateTime t) { + if (_tabs[_tabIdx].showTime) { + return '${t.hour.toString().padLeft(2, '0')}:' + '${t.minute.toString().padLeft(2, '0')}'; + } + return '${t.day}/${t.month}'; + } + + Widget _spinner() => const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, color: AppTheme.accentTeal), + ), + ); + + Widget _empty() => const Center( + child: Text('Aucune donnée', + style: TextStyle(color: AppTheme.textLight, fontSize: 12)), + ); } -class _Legend extends StatelessWidget { - const _Legend(); +// ───────────────────────────────────────────────────────────────────────────── +// Widgets helpers +// ───────────────────────────────────────────────────────────────────────────── + +class _ChartCard extends StatelessWidget { + final String title; + final List<_LegendItem> legend; + final Widget child; + + const _ChartCard({ + required this.title, + required this.legend, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + decoration: BoxDecoration( + color: AppTheme.cardWhite, + borderRadius: BorderRadius.circular(AppTheme.cornerRadius), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.textDark, + fontSize: 13)), + const SizedBox(height: 6), + Wrap( + spacing: 12, + runSpacing: 4, + children: legend, + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} + +class _LegendItem extends StatelessWidget { + final Color color; + final String label; + final bool dashed; + + const _LegendItem({ + required this.color, + required this.label, + this.dashed = false, + }); @override Widget build(BuildContext context) { return Row( + mainAxisSize: MainAxisSize.min, children: [ - _dot(AppTheme.solarYellow), + dashed + ? Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 5, height: 2, color: color), + const SizedBox(width: 2), + Container(width: 5, height: 2, color: color), + ]) + : Container( + width: 12, + height: 3, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2)), + ), const SizedBox(width: 4), - const Text('Production', style: TextStyle(fontSize: 12, color: AppTheme.textLight)), - const SizedBox(width: 16), - _dot(AppTheme.homeBlue), - const SizedBox(width: 4), - const Text('Consommation', style: TextStyle(fontSize: 12, color: AppTheme.textLight)), + Text(label, + style: const TextStyle( + fontSize: 10, color: AppTheme.textLight)), ], ); } - - Widget _dot(Color c) => Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: c, shape: BoxShape.circle), - ); } +// ── Tuiles résumé temps-réel ────────────────────────────────────────────────── + class _SummaryRow extends StatelessWidget { final NymeaService service; @@ -340,64 +624,81 @@ class _SummaryRow extends StatelessWidget { final d = service.energyData; return Row( children: [ - _SummaryCard( - label: 'Production', - value: '${(d.dayProductionWh / 1000).toStringAsFixed(2)} kWh', - color: AppTheme.solarYellow, - icon: Icons.wb_sunny_rounded), + _Tile( + icon: Icons.wb_sunny_rounded, + color: AppTheme.solarYellow, + label: 'Production', + value: _kWh(d.dayProductionWh), + ), const SizedBox(width: 8), - _SummaryCard( - label: 'Consommation', - value: '${(d.daySelfConsumptionWh / 1000).toStringAsFixed(2)} kWh', - color: AppTheme.homeBlue, - icon: Icons.home_rounded), + _Tile( + icon: Icons.home_rounded, + color: AppTheme.homeBlue, + label: 'Consommation', + value: _kWh(d.daySelfConsumptionWh), + ), const SizedBox(width: 8), - _SummaryCard( - label: 'Gains', - value: '${d.dayGains.toStringAsFixed(2)} €', - color: Colors.amber, - icon: Icons.euro_rounded), + _Tile( + icon: Icons.recycling_rounded, + color: AppTheme.accentTeal, + label: 'Autoconso', + value: '${d.selfConsumptionRate.toStringAsFixed(0)} %', + ), + const SizedBox(width: 8), + _Tile( + icon: Icons.euro_rounded, + color: Colors.amber, + label: 'Gains', + value: '${d.dayGains.toStringAsFixed(2)} €', + ), ], ); } + + static String _kWh(double wh) => + '${(wh / 1000).toStringAsFixed(2)} kWh'; } -class _SummaryCard extends StatelessWidget { +class _Tile extends StatelessWidget { + final IconData icon; + final Color color; final String label; final String value; - final Color color; - final IconData icon; - const _SummaryCard({ + const _Tile({ + required this.icon, + required this.color, required this.label, required this.value, - required this.color, - required this.icon, }); @override Widget build(BuildContext context) { return Expanded( - child: Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(height: 6), - Text(value, - style: TextStyle( - fontWeight: FontWeight.bold, - color: color, - fontSize: 13)), - const SizedBox(height: 2), - Text(label, - style: const TextStyle( - fontSize: 10, color: AppTheme.textLight)), - ], - ), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), + decoration: BoxDecoration( + color: AppTheme.cardWhite, + borderRadius: BorderRadius.circular(AppTheme.cornerRadius), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(height: 4), + Text(value, + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text(label, + style: const TextStyle( + fontSize: 9, color: AppTheme.textLight)), + ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/thing_detail_screen.dart b/lib/screens/thing_detail_screen.dart index cae969e..38a06db 100644 --- a/lib/screens/thing_detail_screen.dart +++ b/lib/screens/thing_detail_screen.dart @@ -6,7 +6,14 @@ import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; // ───────────────────────────────────────────────────────────────────────────── -// ThingDetailScreen — fiche complète d'un appareil +// ThingDetailScreen — NIVEAU 3 +// +// • AppBar : ‹ Nom du thing ≡ +// • "States" right-aligned + trait coloré +// • Liste plate SANS Cards — lignes séparées par Divider +// - États writables : Switch interactif / bouton edit → setStateValue +// - Actions : dialog de collecte des paramètres → executeAction +// • "Settings" idem, éditables via setThingSettings // ───────────────────────────────────────────────────────────────────────────── class ThingDetailScreen extends StatelessWidget { @@ -18,8 +25,7 @@ class ThingDetailScreen extends StatelessWidget { Widget build(BuildContext context) { final service = context.watch(); - // Récupérer le thing à jour (les états sont mis à jour en temps réel) - final liveThing = service.things.firstWhere( + final live = service.things.firstWhere( (t) => t.id == thing.id, orElse: () => thing, ); @@ -33,247 +39,266 @@ class ThingDetailScreen extends StatelessWidget { ? categoryInfoMap[cls.category] ?? categoryInfoMap[ThingCategory.other]! : categoryInfoMap[ThingCategory.other]!; - // Grouper les états par thème - final stateGroups = _groupStates(cls?.stateTypes ?? [], liveThing); + final stateTypes = cls?.stateTypes ?? []; + final settingTypes = cls?.settingsTypes ?? []; + final actionTypes = cls?.actionTypes ?? []; return Scaffold( backgroundColor: AppTheme.backgroundGray, - body: CustomScrollView( - slivers: [ - // ── SliverAppBar avec header coloré ───────────────────────────── - SliverAppBar( - expandedHeight: 160, - pinned: true, - backgroundColor: catInfo.color, - foregroundColor: Colors.white, - actions: [ - if (!service.isSimulation) - IconButton( - icon: const Icon(Icons.edit_outlined, color: Colors.white), - onPressed: () => - _showRenameDialog(context, service, liveThing), - ), - ], - flexibleSpace: FlexibleSpaceBar( - background: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - catInfo.color, - catInfo.color.withValues(alpha: 0.7), - ], - ), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 56, 20, 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - width: 56, height: 56, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), + 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: Text( + live.name, + style: const TextStyle( + color: AppTheme.textDark, + fontWeight: FontWeight.bold, + fontSize: 18), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + actions: [ + if (!service.isSimulation) + IconButton( + icon: const Icon(Icons.menu, + color: AppTheme.textDark, size: 22), + onPressed: () => + _showMenu(context, service, live, catInfo), + ), + ], + ), + body: ListView( + children: [ + + // ── States ───────────────────────────────────────────────────────── + if (stateTypes.isNotEmpty) ...[ + _SectionLabel(label: 'States', accentColor: catInfo.color), + _FlatSection( + children: stateTypes.asMap().entries.map((e) { + final isLast = e.key == stateTypes.length - 1; + final st = e.value; + return _StateRow( + stateType: st, + value: live.stateValue(st.id), + isLast: isLast, + accentColor: catInfo.color, + // onChanged non-null si l'état est writable + onChanged: st.writable + ? (newVal) async { + final ok = await service.setStateValue( + live.id, st.id, newVal); + if (!ok && context.mounted) { + _showError(context, 'Modification échouée'); + } + } + : null, + ); + }).toList(), + ), + const SizedBox(height: 20), + ], + + // ── Settings ─────────────────────────────────────────────────────── + if (settingTypes.isNotEmpty) ...[ + _SectionLabel(label: 'Settings', accentColor: catInfo.color), + _FlatSection( + children: settingTypes.asMap().entries.map((e) { + final isLast = e.key == settingTypes.length - 1; + final st = e.value; + return _StateRow( + stateType: st, + value: live.settingValue(st.id), + isLast: isLast, + accentColor: catInfo.color, + // Settings sont toujours éditables + onChanged: (newVal) async { + final ok = await service.setThingSettings( + live.id, st.id, newVal); + if (!ok && context.mounted) { + _showError(context, 'Paramètre non appliqué'); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 20), + ], + + // ── Actions ──────────────────────────────────────────────────────── + if (actionTypes.isNotEmpty) ...[ + _SectionLabel(label: 'Actions', accentColor: catInfo.color), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Wrap( + spacing: 10, + runSpacing: 8, + children: actionTypes + .map((action) => ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: catInfo.color, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppTheme.cornerRadius)), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), ), - child: Icon(catInfo.icon, - color: Colors.white, size: 30), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(liveThing.name, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis), - const SizedBox(height: 2), - Text( - cls?.displayName ?? catInfo.label, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 13), - ), - ], - ), - ), - _OnlineBadge(thing: liveThing, thingClass: cls), - ], - ), - ), - ), + icon: const Icon(Icons.play_arrow_rounded, + size: 18), + label: Text(action.displayName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600)), + onPressed: () => _executeAction( + context, service, live, action, + catInfo.color), + )) + .toList(), ), ), - ), + const SizedBox(height: 20), + ], - // ── Contenu ────────────────────────────────────────────────────── - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - // Actions rapides (si la classe en a) - if (cls != null && cls.actionTypes.isNotEmpty) ...[ - _ActionsSection(thing: liveThing, thingClass: cls), - const SizedBox(height: 16), - ], - - // États groupés - ...stateGroups.entries.map((entry) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionHeader(title: entry.key), - const SizedBox(height: 8), - Card( - child: Column( - children: entry.value.asMap().entries.map((e) { - final isLast = e.key == entry.value.length - 1; - return _StateRow( - stateType: e.value, - value: liveThing.stateValue(e.value.id), - isLast: isLast, - color: catInfo.color, - ); - }).toList(), - ), - ), - const SizedBox(height: 16), - ], - )), - - // Interfaces nymea - if (cls != null && cls.interfaces.isNotEmpty) ...[ - _SectionHeader(title: 'Interfaces'), - const SizedBox(height: 8), - Wrap( - spacing: 8, runSpacing: 6, - children: cls.interfaces.map((iface) => Chip( - label: Text(iface, - style: const TextStyle(fontSize: 12)), - backgroundColor: catInfo.color.withValues(alpha: 0.1), - side: BorderSide(color: catInfo.color.withValues(alpha: 0.3)), - padding: const EdgeInsets.symmetric(horizontal: 4), - )).toList(), - ), - const SizedBox(height: 16), - ], - - // Section Réglages - if (cls != null && cls.settingsTypes.isNotEmpty) ...[ - const _SectionHeader(title: 'Réglages'), - const SizedBox(height: 8), - Card( - child: Column( - children: cls.settingsTypes.asMap().entries.map((e) { - final isLast = e.key == cls!.settingsTypes.length - 1; - final current = liveThing.settingValue(e.value.id); - return _SettingRow( - settingType: e.value, - value: current, - isLast: isLast, - onEdit: (newVal) => service.setThingSettings( - liveThing.id, e.value.id, newVal), - ); - }).toList(), - ), - ), - const SizedBox(height: 16), - ], - - // Info technique - _SectionHeader(title: 'Informations'), - const SizedBox(height: 8), - Card( - child: Column(children: [ - _InfoRow('ID', liveThing.id, isLast: false), - _InfoRow('Classe', liveThing.thingClassId, isLast: false), - _InfoRow('Statut setup', liveThing.setupStatus - .replaceAll('ThingSetupStatus', ''), isLast: true), - ]), - ), - const SizedBox(height: 16), - - // Section Gestion - if (!service.isSimulation) ...[ - const _SectionHeader(title: 'Gestion'), - const SizedBox(height: 8), - Card( - child: Column(children: [ - ListTile( - leading: const Icon(Icons.edit_rounded, - color: AppTheme.primaryGreen), - title: const Text('Renommer'), - onTap: () => - _showRenameDialog(context, service, liveThing), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - ListTile( - leading: const Icon(Icons.delete_outline_rounded, - color: Colors.red), - title: const Text('Supprimer', - style: TextStyle(color: Colors.red)), - onTap: () => - _showRemoveDialog(context, service, liveThing), - ), - ]), - ), - const SizedBox(height: 16), - ], - - const SizedBox(height: 60), - ]), + // ── Interfaces ───────────────────────────────────────────────────── + if (cls != null && cls.interfaces.isNotEmpty) ...[ + _SectionLabel(label: 'Interfaces', accentColor: catInfo.color), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Wrap( + spacing: 8, + runSpacing: 6, + children: cls.interfaces + .map((iface) => Chip( + label: Text(iface, + style: const TextStyle(fontSize: 12)), + backgroundColor: + catInfo.color.withValues(alpha: 0.1), + side: BorderSide( + color: + catInfo.color.withValues(alpha: 0.3)), + padding: const EdgeInsets.symmetric( + horizontal: 4), + )) + .toList(), + ), ), - ), + const SizedBox(height: 20), + ], + + // ── Informations ─────────────────────────────────────────────────── + _SectionLabel(label: 'Informations', accentColor: catInfo.color), + _FlatSection(children: [ + _InfoRow('ID', live.id, isLast: false), + _InfoRow('Classe', live.thingClassId, isLast: false), + _InfoRow( + 'Statut', + live.setupStatus.replaceAll('ThingSetupStatus', ''), + isLast: true), + ]), + + const SizedBox(height: 60), ], ), ); } - // ── Groupement des états par thème ──────────────────────────────────────── + // ── Exécuter une action ───────────────────────────────────────────────────── - Map> _groupStates( - List stateTypes, - NymeaThing thing, - ) { - final Map> groups = {}; + void _executeAction( + BuildContext context, + NymeaService service, + NymeaThing t, + NymeaActionType action, + Color accentColor, + ) async { + Map? params; - for (final st in stateTypes) { - final group = _groupName(st.name); - groups.putIfAbsent(group, () => []).add(st); + if (action.paramTypes.isEmpty) { + params = {}; + } else { + // Collecter les valeurs des paramètres via un dialog + params = await showDialog>( + context: context, + builder: (_) => _ActionParamDialog( + actionName: action.displayName, + paramTypes: action.paramTypes, + accentColor: accentColor, + ), + ); + if (params == null) return; // annulé } - // Trier : Puissance en premier, puis alphabétique - final ordered = >{}; - const firstGroups = ['Puissance', 'Énergie', 'Mesures électriques', 'État']; - for (final g in firstGroups) { - if (groups.containsKey(g)) ordered[g] = groups[g]!; - } - for (final entry in groups.entries) { - if (!ordered.containsKey(entry.key)) ordered[entry.key] = entry.value; - } + final result = await service.executeAction( + thingId: t.id, + actionTypeId: action.id, + params: params, + ); - return ordered; + if (!context.mounted) return; + + if (result.success) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Row(children: [ + const Icon(Icons.check_circle_outline, + color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded(child: Text('${action.displayName} exécutée')), + ]), + backgroundColor: accentColor, + duration: const Duration(seconds: 2), + )); + } else { + _showError(context, result.errorText); + } } - String _groupName(String stateName) { - final n = stateName.toLowerCase(); - if (n.contains('power') || n.contains('currentpower')) { return 'Puissance'; } - if (n.contains('energy') || n.contains('total')) { return 'Énergie'; } - if (n.contains('voltage') || n.contains('current') || n.contains('frequency') - || n.contains('ampere') || n.contains('volt') || n.contains('hertz') - || n.contains('powerfactor')) { return 'Mesures électriques'; } - if (n.contains('temperature') || n.contains('humidity') || n.contains('pressure') - || n.contains('co2') || n.contains('lux')) { return 'Environnement'; } - if (n.contains('connected') || n.contains('reachable') || n.contains('status') - || n.contains('mode') || n.contains('on')) { return 'État'; } - if (n.contains('soc') || n.contains('battery') || n.contains('charging')) { return 'Batterie'; } - return 'Divers'; + // ── Menu ≡ ───────────────────────────────────────────────────────────────── + + void _showMenu(BuildContext context, NymeaService service, NymeaThing t, + ThingCategoryInfo info) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 36, height: 4, + margin: const EdgeInsets.only(top: 10, bottom: 12), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2)), + ), + ListTile( + leading: Icon(Icons.edit_rounded, color: info.color), + title: const Text('Renommer'), + onTap: () { + Navigator.pop(context); + _showRenameDialog(context, service, t); + }, + ), + const Divider(height: 1, color: Color(0xFFEEEEEE)), + ListTile( + leading: + const Icon(Icons.delete_outline_rounded, color: Colors.red), + title: const Text('Supprimer', + style: TextStyle(color: Colors.red)), + onTap: () { + Navigator.pop(context); + _showRemoveDialog(context, service, t); + }, + ), + const SizedBox(height: 8), + ]), + ), + ); } void _showRenameDialog( @@ -286,7 +311,8 @@ class ThingDetailScreen extends StatelessWidget { content: TextField( controller: ctrl, autofocus: true, - decoration: const InputDecoration(border: OutlineInputBorder()), + decoration: + const InputDecoration(border: OutlineInputBorder()), ), actions: [ TextButton( @@ -318,7 +344,8 @@ class ThingDetailScreen extends StatelessWidget { child: const Text('Annuler')), ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, foregroundColor: Colors.white), + backgroundColor: Colors.red, + foregroundColor: Colors.white), onPressed: () async { await svc.removeThing(t.id); if (c.mounted) { @@ -332,164 +359,423 @@ class ThingDetailScreen extends StatelessWidget { ), ); } + + static void _showError(BuildContext context, String msg) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Row(children: [ + const Icon(Icons.error_outline, color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(msg.isEmpty ? 'Erreur' : msg)), + ]), + backgroundColor: Colors.redAccent, + duration: const Duration(seconds: 3), + )); + } } -// ─── Section actions ────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// _ActionParamDialog — collecte les paramètres d'une action nymea +// +// Construit automatiquement le formulaire selon le type de chaque paramètre : +// • Bool → Switch +// • allowedValues → DropdownButton +// • Int/Double → TextField numérique +// • String → TextField texte +// ───────────────────────────────────────────────────────────────────────────── -class _ActionsSection extends StatelessWidget { - final NymeaThing thing; - final NymeaThingClass thingClass; - const _ActionsSection({required this.thing, required this.thingClass}); +class _ActionParamDialog extends StatefulWidget { + final String actionName; + final List> paramTypes; + final Color accentColor; + + const _ActionParamDialog({ + required this.actionName, + required this.paramTypes, + required this.accentColor, + }); @override - Widget build(BuildContext context) { - final service = context.read(); + State<_ActionParamDialog> createState() => _ActionParamDialogState(); +} - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader(title: 'Actions'), - const SizedBox(height: 8), - Wrap( - spacing: 10, runSpacing: 8, - children: thingClass.actionTypes.map((action) => ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryGreen, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text(action.displayName, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), - onPressed: () => _executeAction(context, service, action), - )).toList(), - ), - ]); - } +class _ActionParamDialogState extends State<_ActionParamDialog> { + late Map _values; + late Map _controllers; - void _executeAction(BuildContext context, NymeaService service, - NymeaActionType action) async { - if (action.paramTypes.isEmpty) { - // Action sans paramètre → exécuter directement - await service.executeAction( - thingId: thing.id, - actionTypeId: action.id, - params: {}, - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Action "${action.displayName}" exécutée'), - backgroundColor: AppTheme.primaryGreen, - duration: const Duration(seconds: 2), - )); + @override + void initState() { + super.initState(); + _values = {}; + _controllers = {}; + for (final p in widget.paramTypes) { + final id = p['id'] as String? ?? ''; + final defVal = p['defaultValue']; + _values[id] = defVal; + // Contrôleur texte pour les types non-bool + if ((p['type'] as String? ?? '') != 'Bool') { + _controllers[id] = + TextEditingController(text: defVal?.toString() ?? ''); } - } else { - // Action avec paramètres → dialog - _showActionParamDialog(context, service, action); } } - void _showActionParamDialog(BuildContext context, NymeaService service, - NymeaActionType action) { - // Simple pour l'instant : juste afficher les paramètres requis - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(action.displayName), - content: Column( - mainAxisSize: MainAxisSize.min, - children: action.paramTypes.map((p) => ListTile( - dense: true, - title: Text(p['displayName']?.toString() ?? p['name']?.toString() ?? ''), - subtitle: Text('Type: ${p['type']}'), - )).toList(), + @override + void dispose() { + for (final c in _controllers.values) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row(children: [ + Icon(Icons.play_arrow_rounded, + color: widget.accentColor, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text(widget.actionName, + style: const TextStyle(fontSize: 16)), ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), - child: const Text('Fermer')), - ], + ]), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.cornerRadius + 4)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.paramTypes.map(_buildParamWidget).toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler')), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: widget.accentColor), + onPressed: () => Navigator.pop(context, _values), + child: const Text('Exécuter'), + ), + ], + ); + } + + Widget _buildParamWidget(Map param) { + final id = param['id'] as String? ?? ''; + final name = param['displayName'] as String? + ?? param['name'] as String? + ?? id; + final type = param['type'] as String? ?? 'String'; + final allowed = (param['allowedValues'] as List?)?.cast(); + final unit = param['unit'] as String? ?? ''; + + // ── Bool ───────────────────────────────────────────────────────────── + if (type == 'Bool') { + final isTrue = + _values[id] == true || _values[id]?.toString() == 'true'; + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(name, style: const TextStyle(fontSize: 14)), + value: isTrue, + activeThumbColor: widget.accentColor, + onChanged: (v) => setState(() => _values[id] = v), + ); + } + + // ── Enum (allowedValues) ────────────────────────────────────────────── + if (allowed != null && allowed.isNotEmpty) { + final current = _values[id]?.toString() ?? allowed.first.toString(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, + style: const TextStyle( + fontSize: 12, color: AppTheme.textLight)), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[400]!), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButton( + value: current, + underline: const SizedBox(), + isExpanded: true, + items: allowed + .map((v) => DropdownMenuItem( + value: v.toString(), + child: Text(v.toString()), + )) + .toList(), + onChanged: (v) => setState(() => _values[id] = v), + ), + ), + ]), + ); + } + + // ── Int / Double / String ───────────────────────────────────────────── + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: _controllers[id], + decoration: InputDecoration( + labelText: name, + suffixText: unit.isNotEmpty ? unit : null, + border: const OutlineInputBorder(), + isDense: true, + ), + keyboardType: (type == 'Int' || type == 'Double') + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.text, + onChanged: (v) { + if (type == 'Int') { + _values[id] = int.tryParse(v) ?? _values[id]; + } else if (type == 'Double') { + _values[id] = double.tryParse(v) ?? _values[id]; + } else { + _values[id] = v; + } + }, ), ); } } -// ─── Widgets utilitaires ────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// _SectionLabel — label right-aligned + trait coloré (style nymea) +// ───────────────────────────────────────────────────────────────────────────── -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); +class _SectionLabel extends StatelessWidget { + final String label; + final Color accentColor; + const _SectionLabel({required this.label, required this.accentColor}); @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(left: 4), - child: Text(title, - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w700, - color: AppTheme.textLight, letterSpacing: 0.5)), - ); + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(label, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textLight, + fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Container(height: 1.5, color: accentColor), + ]), + ); } +// ───────────────────────────────────────────────────────────────────────────── +// _FlatSection — conteneur blanc sans Card +// ───────────────────────────────────────────────────────────────────────────── + +class _FlatSection extends StatelessWidget { + final List children; + const _FlatSection({required this.children}); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.cardWhite, + borderRadius: BorderRadius.circular(AppTheme.cornerRadius), + ), + child: Column(children: children), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// _StateRow — affiche un état nymea. Writable = interactif. +// +// onChanged != null → état writable (Switch interactif ou bouton edit) +// onChanged == null → read-only +// ───────────────────────────────────────────────────────────────────────────── + class _StateRow extends StatelessWidget { final NymeaStateType stateType; final dynamic value; final bool isLast; - final Color color; + final Color accentColor; + /// Callback appelé avec la nouvelle valeur. Null = read-only. + final Future Function(dynamic)? onChanged; const _StateRow({ - required this.stateType, required this.value, - required this.isLast, required this.color, + required this.stateType, + required this.value, + required this.isLast, + required this.accentColor, + this.onChanged, }); + bool get _isBool => stateType.type == 'Bool'; + bool get _isTrue => value == true || value == 'true'; + bool get _isConnected => stateType.name.toLowerCase() == 'connected'; + bool get _isWritable => onChanged != null; + @override Widget build(BuildContext context) { - final formatted = stateType.formatValue(value); - final isBool = stateType.type == 'Bool'; - return Column(children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [ + // Nom de l'état Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(stateType.displayName, - style: const TextStyle( - fontSize: 14, color: AppTheme.textDark, - fontWeight: FontWeight.w500)), - if (stateType.name != stateType.displayName) - Text(stateType.name, - style: const TextStyle( - fontSize: 10, color: AppTheme.textLight)), - ]), + child: Text(stateType.displayName, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textDark, + fontWeight: FontWeight.w400)), ), - if (isBool) + + // ── Valeur / contrôle ──────────────────────────────────────── + if (_isConnected) + // Pastille "Connected" avec glow Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + width: 14, height: 14, decoration: BoxDecoration( - color: (value == true || value == 'true') - ? AppTheme.primaryGreen.withValues(alpha: 0.15) - : Colors.grey.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(20), + color: _isTrue ? accentColor : Colors.grey[400], + shape: BoxShape.circle, + boxShadow: _isTrue + ? [BoxShadow( + color: accentColor.withValues(alpha: 0.4), + blurRadius: 4)] + : null, ), - child: Text(formatted, - style: TextStyle( - fontSize: 12, fontWeight: FontWeight.bold, - color: (value == true || value == 'true') - ? AppTheme.primaryGreen : AppTheme.textLight)), ) - else - Text(formatted, - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.bold, - color: color)), + else if (_isBool) + // Switch — interactif si writable + Switch( + value: _isTrue, + activeThumbColor: accentColor, + onChanged: _isWritable ? (v) => onChanged!(v) : null, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + else ...[ + // Valeur texte + Text( + stateType.formatValue(value), + style: TextStyle( + fontSize: 14, + color: AppTheme.textDark, + fontWeight: FontWeight.w500), + ), + // Bouton edit si writable + if (_isWritable) ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => _showEditDialog(context), + child: Icon(Icons.edit_outlined, + size: 17, color: accentColor.withValues(alpha: 0.7)), + ), + ], + ], ]), ), if (!isLast) - const Divider(height: 1, indent: 16, endIndent: 16, + const Divider( + height: 1, indent: 16, endIndent: 16, color: Color(0xFFEEEEEE)), ]); } + + /// Dialog d'édition pour les états non-bool writables. + void _showEditDialog(BuildContext context) async { + final allowed = stateType.allowedValues; + + // ── Enum (allowedValues) ───────────────────────────────────────────── + if (allowed != null && allowed.isNotEmpty) { + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(stateType.displayName), + children: allowed + .map((v) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, v), + child: Text(v.toString(), + style: TextStyle( + color: v.toString() == value?.toString() + ? accentColor + : AppTheme.textDark, + fontWeight: + v.toString() == value?.toString() + ? FontWeight.bold + : FontWeight.normal)), + )) + .toList(), + ), + ); + if (selected != null && context.mounted) { + await onChanged!(selected); + } + return; + } + + // ── Numérique / texte ──────────────────────────────────────────────── + final ctrl = TextEditingController(text: value?.toString() ?? ''); + final newVal = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(stateType.displayName), + content: TextField( + controller: ctrl, + autofocus: true, + keyboardType: (stateType.type == 'Int' || stateType.type == 'Double') + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.text, + decoration: InputDecoration( + border: const OutlineInputBorder(), + suffixText: stateType.unit.isNotEmpty ? stateType.unit : null, + helperText: _rangeHint(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: accentColor), + onPressed: () { + dynamic v = ctrl.text.trim(); + if (stateType.type == 'Int') { + v = int.tryParse(ctrl.text.trim()) ?? value; + } else if (stateType.type == 'Double') { + v = double.tryParse(ctrl.text.trim()) ?? value; + } + Navigator.pop(ctx, v); + }, + child: const Text('Appliquer'), + ), + ], + ), + ); + if (newVal != null && context.mounted) { + await onChanged!(newVal); + } + } + + /// Hint de plage min/max pour le TextField. + String? _rangeHint() { + final min = stateType.minValue; + final max = stateType.maxValue; + if (min != null && max != null) { + return 'Entre ${min.toStringAsFixed(0)} et ${max.toStringAsFixed(0)}'; + } + if (min != null) return 'Min : ${min.toStringAsFixed(0)}'; + if (max != null) return 'Max : ${max.toStringAsFixed(0)}'; + return null; + } } +// ───────────────────────────────────────────────────────────────────────────── +// _InfoRow — ligne read-only (ID, classe, statut) +// ───────────────────────────────────────────────────────────────────────────── + class _InfoRow extends StatelessWidget { final String label; final String value; @@ -498,158 +784,27 @@ class _InfoRow extends StatelessWidget { @override Widget build(BuildContext context) => Column(children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row(children: [ - Text(label, - style: const TextStyle(fontSize: 13, color: AppTheme.textLight)), - const SizedBox(width: 12), - Expanded( - child: SelectableText(value, - textAlign: TextAlign.end, - style: const TextStyle( - fontSize: 12, fontWeight: FontWeight.w500, - color: AppTheme.textDark)), - ), - ]), - ), - if (!isLast) const Divider(height: 1, indent: 16, endIndent: 16, - color: Color(0xFFEEEEEE)), - ]); -} - -class _SettingRow extends StatelessWidget { - final NymeaStateType settingType; - final dynamic value; - final bool isLast; - final Future Function(dynamic) onEdit; - - const _SettingRow({ - required this.settingType, - required this.value, - required this.isLast, - required this.onEdit, - }); - - @override - Widget build(BuildContext context) { - final formatted = settingType.formatValue(value); - return Column(children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Row(children: [ - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(settingType.displayName, + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Row(children: [ + Text(label, + style: const TextStyle( + fontSize: 13, color: AppTheme.textLight)), + const SizedBox(width: 16), + Expanded( + child: SelectableText(value, + textAlign: TextAlign.end, style: const TextStyle( - fontSize: 14, color: AppTheme.textDark, - fontWeight: FontWeight.w500)), - if (settingType.name != settingType.displayName) - Text(settingType.name, - style: const TextStyle( - fontSize: 10, color: AppTheme.textLight)), - ]), - ), - Text(formatted, - style: const TextStyle( - fontSize: 14, color: AppTheme.textLight)), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.edit_outlined, size: 18, - color: AppTheme.textLight), - onPressed: () => _showEditDialog(context), - ), - ]), - ), - if (!isLast) - const Divider(height: 1, indent: 16, endIndent: 16, - color: Color(0xFFEEEEEE)), - ]); - } - - void _showEditDialog(BuildContext context) { - if (settingType.type == 'Bool') { - final current = value == true || value == 'true'; - onEdit(!current); - return; - } - final ctrl = TextEditingController(text: value?.toString() ?? ''); - showDialog( - context: context, - builder: (c) => AlertDialog( - title: Text(settingType.displayName), - content: TextField( - controller: ctrl, - autofocus: true, - keyboardType: (settingType.type == 'Int' || settingType.type == 'Double') - ? const TextInputType.numberWithOptions(decimal: true) - : TextInputType.text, - decoration: InputDecoration( - border: const OutlineInputBorder(), - suffixText: settingType.unit, - ), + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textDark)), + ), + ]), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c), - child: const Text('Annuler')), - ElevatedButton( - onPressed: () { - dynamic newVal = ctrl.text.trim(); - if (settingType.type == 'Int') { - newVal = int.tryParse(ctrl.text.trim()) ?? value; - } else if (settingType.type == 'Double') { - newVal = double.tryParse(ctrl.text.trim()) ?? value; - } - onEdit(newVal); - Navigator.pop(c); - }, - child: const Text('Enregistrer'), - ), - ], - ), - ); - } + if (!isLast) + const Divider( + height: 1, indent: 16, endIndent: 16, + color: Color(0xFFEEEEEE)), + ]); } - -class _OnlineBadge extends StatelessWidget { - final NymeaThing thing; - final NymeaThingClass? thingClass; - const _OnlineBadge({required this.thing, required this.thingClass}); - - 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; - } - - @override - Widget build(BuildContext context) { - final online = _isOnline(); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 8, height: 8, - decoration: BoxDecoration( - color: online ? Colors.greenAccent : Colors.grey[300], - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text(online ? 'En ligne' : 'Hors ligne', - style: const TextStyle( - color: Colors.white, fontSize: 12, - fontWeight: FontWeight.w600)), - ]), - ); - } -} \ No newline at end of file diff --git a/lib/screens/things_screen.dart b/lib/screens/things_screen.dart index 9385d55..c15fa04 100644 --- a/lib/screens/things_screen.dart +++ b/lib/screens/things_screen.dart @@ -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 createState() => _ThingsScreenState(); -} -class _ThingsScreenState extends State { - final Set _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(); - // 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> 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 { 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 { ); } - 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 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 classes) { - try { return classes.firstWhere((c) => c.id == thing.thingClassId); } - catch (_) { return null; } + static NymeaThingClass? _classFor( + NymeaThing t, List 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 things; final List 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 _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( - 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().renameThing(thing.id, ctrl.text.trim()); - Navigator.pop(ctx); - }, - child: const Text('Renommer'), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 21f04d3..f023d3a 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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 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> 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((e) { + final m = e as Map; + 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 _simulatePowerBalance( + DateTime from, DateTime to, String sampleRate) { + final entries = []; + 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 refresh() async { await Future.wait([_loadThings(), _loadEnergyStatus()]); @@ -785,22 +866,129 @@ class NymeaService extends ChangeNotifier { } } - Future executeAction({ + /// Exécute une action sur un thing. + /// Retourne un [NymeaActionResult] avec le thingError et un message humain. + Future executeAction({ required String thingId, required String actionTypeId, Map 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 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.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> 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 = []; + 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((e) { + final ts = DateTime.fromMillisecondsSinceEpoch( + (e['timestamp'] as num).toInt()); + final values = (e['values'] as Map?)?.cast() ?? {}; + final v = (values[stateTypeName] as num?)?.toDouble() ?? 0.0; + return HistoryEntry(timestamp: ts, value: v); + }).toList(); + } catch (e) { + _log('fetchHistory: $e'); + return []; + } } Future getStateValue(String thingId, String stateTypeId) async { diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 2ee3ac4..dde9b32 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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, diff --git a/lib/widgets/energy_flow_widget.dart b/lib/widgets/energy_flow_widget.dart index 7b70dbd..0647685 100644 --- a/lib/widgets/energy_flow_widget.dart +++ b/lib/widgets/energy_flow_widget.dart @@ -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( diff --git a/lib/widgets/nymea_tile.dart b/lib/widgets/nymea_tile.dart new file mode 100644 index 0000000..66fc1ac --- /dev/null +++ b/lib/widgets/nymea_tile.dart @@ -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 createState() => _NymeaTileState(); +} + +class _NymeaTileState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _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, + ), + ); +} diff --git a/lib/widgets/production_card.dart b/lib/widgets/production_card.dart index af5f1bc..8cb1339 100644 --- a/lib/widgets/production_card.dart +++ b/lib/widgets/production_card.dart @@ -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, diff --git a/lib/widgets/state_history_chart.dart b/lib/widgets/state_history_chart.dart new file mode 100644 index 0000000..52a21ad --- /dev/null +++ b/lib/widgets/state_history_chart.dart @@ -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 createState() => _StateHistoryChartState(); +} + +class _StateHistoryChartState extends State { + // 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 _data = []; + bool _loading = false; + bool _noData = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetch()); + } + + Future _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(); + 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 = []; + 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); +}