etm-powersync-app/lib/screens/energy_screen.dart
pakutz79 c19c9d1a98 feat: navigation drawer, EMS setup, scheduler, tarifs, paramètres app
- 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>
2026-02-24 14:52:32 +01:00

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)),
],
),
),
);
}
}