- Drawer custom (overlay Stack) avec mode installateur PIN SHA-256 - GoRouter + ShellRoute : navigation préservée entre onglets - 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider, EnergySetupProvider, SchedulerProvider, TariffProvider - Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline - Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen - DrawerMenuButton présent dans les 5 AppBars principaux - Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles - Compteur solaire : ajout smartmeter aux interfaces compatibles - Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard - Dépendances : go_router, shared_preferences, crypto, url_launcher Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
830 lines
29 KiB
Dart
830 lines
29 KiB
Dart
import 'dart:async';
|
|
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 '../models/nymea_models.dart';
|
|
import '../services/nymea_service.dart';
|
|
import '../theme/app_theme.dart';
|
|
import '../main.dart' show DrawerMenuButton;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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});
|
|
|
|
@override
|
|
State<EnergyScreen> createState() => _EnergyScreenState();
|
|
}
|
|
|
|
class _EnergyScreenState extends State<EnergyScreen> {
|
|
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<PowerBalanceEntry> _data = [];
|
|
List<HistoryEntry> _socData = []; // historique SOC batterie (%)
|
|
bool _loading = true; // true dès le départ → spinner jusqu'au premier fetch
|
|
bool _noData = false;
|
|
DateTime? _selectedDate; // null = aujourd'hui (date courante)
|
|
Timer? _refreshTimer;
|
|
|
|
// Fix IndexedStack : initState de tous les écrans se déclenche au démarrage,
|
|
// avant que les things soient chargés → on ajoute un listener pour re-fetcher
|
|
// le SOC dès que les things deviennent disponibles.
|
|
NymeaService? _nymeaService;
|
|
bool _initialSocFetched = false; // évite les re-fetch infinis via le listener
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_nymeaService = context.read<NymeaService>();
|
|
_nymeaService!.addListener(_onServiceChangedForSoc);
|
|
_fetch();
|
|
});
|
|
// Rafraîchit le graphe toutes les 5 minutes pour garder les données à jour
|
|
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) {
|
|
if (mounted) _fetch();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_refreshTimer?.cancel();
|
|
_nymeaService?.removeListener(_onServiceChangedForSoc);
|
|
super.dispose();
|
|
}
|
|
|
|
/// Déclenche un fetch SOC silencieux quand les things deviennent disponibles
|
|
/// (cas où l'IndexedStack a construit l'écran avant la connexion nymea).
|
|
void _onServiceChangedForSoc() {
|
|
if (!mounted || _initialSocFetched || _loading) return;
|
|
if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) {
|
|
_initialSocFetched = true;
|
|
_fetchSocOnly();
|
|
}
|
|
}
|
|
|
|
/// Récupère uniquement l'historique SOC sans re-fetcher le bilan de puissance.
|
|
Future<void> _fetchSocOnly() async {
|
|
if (!mounted) return;
|
|
final tab = _tabs[_tabIdx];
|
|
final to = _selectedDate != null
|
|
? DateTime(
|
|
_selectedDate!.year, _selectedDate!.month, _selectedDate!.day,
|
|
23, 59, 59)
|
|
: DateTime.now();
|
|
final service = context.read<NymeaService>();
|
|
final socSource = service.batterySOCSource;
|
|
if (socSource == null) return;
|
|
final soc = await service.fetchHistory(
|
|
thingId: socSource['thingId']!,
|
|
stateTypeName: socSource['stateName']!,
|
|
from: to.subtract(tab.range),
|
|
to: to,
|
|
sampleRate: tab.sampleRate,
|
|
);
|
|
if (!mounted) return;
|
|
if (soc.isNotEmpty) {
|
|
setState(() => _socData = soc);
|
|
}
|
|
}
|
|
|
|
Future<void> _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 from = to.subtract(tab.range);
|
|
final service = context.read<NymeaService>();
|
|
// SOC indisponible en simulation (fetchHistory retourne des W, pas des %)
|
|
final socSource = service.batterySOCSource; // null si pas de batterie
|
|
|
|
// Récupère bilan de puissance et historique SOC en parallèle
|
|
final results = await Future.wait<dynamic>([
|
|
service.fetchPowerBalanceLogs(
|
|
from: from,
|
|
to: to,
|
|
sampleRate: tab.sampleRate,
|
|
),
|
|
socSource != null
|
|
? service.fetchHistory(
|
|
thingId: socSource['thingId']!,
|
|
stateTypeName: socSource['stateName']!,
|
|
from: from,
|
|
to: to,
|
|
sampleRate: tab.sampleRate,
|
|
)
|
|
: Future<List<HistoryEntry>>.value([]),
|
|
]);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_data = results[0] as List<PowerBalanceEntry>;
|
|
_socData = results[1] as List<HistoryEntry>;
|
|
_loading = false;
|
|
_noData = _data.isEmpty;
|
|
});
|
|
}
|
|
|
|
Future<void> _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; _initialSocFetched = false; });
|
|
_fetch();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<NymeaService>(
|
|
builder: (context, service, _) {
|
|
return Scaffold(
|
|
backgroundColor: AppTheme.backgroundGray,
|
|
appBar: AppBar(
|
|
backgroundColor: AppTheme.backgroundGray,
|
|
elevation: 0,
|
|
leading: const DrawerMenuButton(),
|
|
leadingWidth: 56,
|
|
title: const Text('Énergie',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold, color: AppTheme.textDark)),
|
|
actions: [
|
|
IconButton(
|
|
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: _fetch,
|
|
),
|
|
],
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// ── Tuiles résumé ───────────────────────────────────────────
|
|
_SummaryRow(service: service),
|
|
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) · SOC %',
|
|
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: 'SOC %', 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(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── 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; _initialSocFetched = false; });
|
|
_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 = <FlSpot>[];
|
|
final consoSpots = <FlSpot>[];
|
|
final autoSpots = <FlSpot>[];
|
|
final socSpots = <FlSpot>[]; // SOC % mis à l'échelle des W
|
|
|
|
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));
|
|
}
|
|
|
|
// min/max sur production + consommation (toujours ≥ 0)
|
|
final allY = _data.expand((d) => [d.productionW, d.consumptionW]).toList();
|
|
final minY = allY.isEmpty ? 0.0 : allY.reduce(min);
|
|
final maxY = allY.isEmpty ? 1000.0 : allY.reduce(max);
|
|
final spread = (maxY - minY) > 0 ? maxY - minY : 200.0;
|
|
final yPad = spread * 0.12;
|
|
|
|
// Facteur d'échelle : SOC 100 % → y = socMaxW (sommet du graphe)
|
|
final socMaxW = max(maxY + yPad, 100.0);
|
|
// SOC disponible si on a au moins 2 points (pour interpoler sur l'axe X)
|
|
final hasSoc = _socData.length > 1;
|
|
|
|
if (hasSoc) {
|
|
final n = _socData.length;
|
|
final xMax = (_data.length - 1).toDouble();
|
|
for (int i = 0; i < n; i++) {
|
|
// Normalise l'index SOC sur la plage X du power chart
|
|
final x = xMax * i / (n - 1);
|
|
final scaled = _socData[i].value * socMaxW / 100.0;
|
|
socSpots.add(FlSpot(x, scaled));
|
|
}
|
|
}
|
|
|
|
final xInterval = _xInterval();
|
|
|
|
return LineChart(
|
|
LineChartData(
|
|
clipData: const FlClipData.all(),
|
|
minY: minY - yPad,
|
|
maxY: socMaxW,
|
|
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)),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
// Axe Y droit : SOC % (affiché seulement si données SOC disponibles)
|
|
rightTitles: hasSoc
|
|
? AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 36,
|
|
// interval : socMaxW / 4 → labels à 0 / 25 / 50 / 75 / 100 %
|
|
interval: socMaxW / 4,
|
|
getTitlesWidget: (v, _) {
|
|
final pct = (v / socMaxW * 100).round();
|
|
if (pct < 0 || pct > 100) return const SizedBox.shrink();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text('$pct%',
|
|
style: const TextStyle(
|
|
fontSize: 8.5,
|
|
color: AppTheme.batteryGreen),
|
|
textAlign: TextAlign.left),
|
|
);
|
|
},
|
|
),
|
|
)
|
|
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
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), // index 0
|
|
_lineSeries(consoSpots, AppTheme.homeBlue), // index 1
|
|
_lineSeries(autoSpots, AppTheme.accentTeal), // index 2
|
|
if (hasSoc)
|
|
_lineSeries(socSpots, AppTheme.batteryGreen, dashed: true), // index 3
|
|
],
|
|
lineTouchData: LineTouchData(
|
|
touchTooltipData: LineTouchTooltipData(
|
|
getTooltipItems: (spots) => spots.map((s) {
|
|
// Série index 3 = SOC → tooltip en %
|
|
final isSoc = hasSoc && s.barIndex == 3;
|
|
final label = isSoc
|
|
? '${(s.y / socMaxW * 100).toStringAsFixed(0)} %'
|
|
: _fmtW(s.y);
|
|
return LineTooltipItem(
|
|
label,
|
|
TextStyle(
|
|
color: s.bar.color ?? Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
LineChartBarData _lineSeries(List<FlSpot> 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 = <BarChartGroupData>[];
|
|
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)),
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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: [
|
|
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),
|
|
Text(label,
|
|
style: const TextStyle(
|
|
fontSize: 10, color: AppTheme.textLight)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Tuiles résumé temps-réel ──────────────────────────────────────────────────
|
|
|
|
class _SummaryRow extends StatelessWidget {
|
|
final NymeaService service;
|
|
|
|
const _SummaryRow({required this.service});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final d = service.energyData;
|
|
return Row(
|
|
children: [
|
|
_Tile(
|
|
icon: Icons.wb_sunny_rounded,
|
|
color: AppTheme.solarYellow,
|
|
label: 'Production',
|
|
value: _kWh(d.dayProductionWh),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_Tile(
|
|
icon: Icons.home_rounded,
|
|
color: AppTheme.homeBlue,
|
|
label: 'Consommation',
|
|
value: _kWh(d.daySelfConsumptionWh),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_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 _Tile extends StatelessWidget {
|
|
final IconData icon;
|
|
final Color color;
|
|
final String label;
|
|
final String value;
|
|
|
|
const _Tile({
|
|
required this.icon,
|
|
required this.color,
|
|
required this.label,
|
|
required this.value,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|