Lot Connexions multi-HEMS : - modèle Installation + InstallationStore (persistance par UUID ; métadonnées SharedPreferences, token via flutter_secure_storage) - ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo, connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP - nymea_service : connect() réveillé (ws://4444, token par UUID), capture Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState - écran Installations (3 états) + déverrouillage installateur depuis la racine - écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser), détection auto via initialSetupRequired, segmenteur de repli - gate de routage (redirect + refreshListenable merge[cm,svc]) ; hasActiveConnection = isSimulation || (connected && authenticated) - en-tête drawer cliquable → Installations - invalidation des caches au switch (service.resetState + clearForSwitch rôles/scheduler/tariff), sans persist() Lot Rôles & appareils : - EnergySetupProvider refondu en tri-état (present/absent/non-configuré) - écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie) - LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ; Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé Correctifs : - bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2 → consumptionW/home en .abs() (autoconso n'est plus clouée à 0) - UUID Hello brace-wrapped normalisé Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts). Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur. Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
6.7 KiB
Dart
225 lines
6.7 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../../../models/energy_data.dart';
|
||
import '../../../theme/etm_tokens.dart';
|
||
import '../painters/ring_painter.dart';
|
||
|
||
class KpiRow extends StatelessWidget {
|
||
final EnergyData data;
|
||
const KpiRow({super.key, required this.data});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final autoconso = data.selfConsumptionRate.clamp(0.0, 100.0);
|
||
final autonomy = data.autonomyRate.clamp(0.0, 100.0);
|
||
final toGrid = data.dayGridInjectionWh.clamp(0.0, double.infinity) / 1000;
|
||
final fromGrid = (-data.dayGridInjectionWh).clamp(0.0, double.infinity) / 1000;
|
||
|
||
return Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _RingTile(
|
||
label: 'Autoconsommation',
|
||
value: autoconso,
|
||
color: EtmTokens.solarOf(context),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _RingTile(
|
||
label: 'Autonomie',
|
||
value: autonomy,
|
||
color: EtmTokens.ecoOf(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _ValueTile(
|
||
label: 'Vers réseau · aujourd\'hui',
|
||
value: toGrid,
|
||
icon: Icons.north_west_rounded,
|
||
color: EtmTokens.ecoOf(context),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _ValueTile(
|
||
label: 'Depuis réseau · aujourd\'hui',
|
||
value: fromGrid,
|
||
icon: Icons.south_east_rounded,
|
||
color: EtmTokens.importColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Ring tile — horizontal (ring left, text right) ────────────────────────────
|
||
|
||
class _RingTile extends StatefulWidget {
|
||
final String label;
|
||
final double value;
|
||
final Color color;
|
||
const _RingTile({required this.label, required this.value, required this.color});
|
||
|
||
@override
|
||
State<_RingTile> createState() => _RingTileState();
|
||
}
|
||
|
||
class _RingTileState extends State<_RingTile>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _ctrl;
|
||
late Animation<double> _ring;
|
||
late Animation<int> _count;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100));
|
||
_ring = Tween<double>(begin: 0, end: widget.value / 100)
|
||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||
_count = IntTween(begin: 0, end: widget.value.round())
|
||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||
_ctrl.forward();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||
boxShadow: EtmTokens.cardShadowOf(context),
|
||
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||
child: Row(
|
||
children: [
|
||
// Ring — 50×50, strokeWidth 5 (matches HTML .ring SVG)
|
||
SizedBox(
|
||
width: 50, height: 50,
|
||
child: AnimatedBuilder(
|
||
animation: _ring,
|
||
builder: (context, _) => CustomPaint(
|
||
size: const Size(50, 50),
|
||
painter: RingPainter(
|
||
value: _ring.value,
|
||
color: widget.color,
|
||
strokeWidth: 5,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
AnimatedBuilder(
|
||
animation: _count,
|
||
builder: (context, _) => Text(
|
||
'${_count.value} %',
|
||
style: EtmTokens.mono(
|
||
size: 18,
|
||
weight: FontWeight.w600,
|
||
color: EtmTokens.inkOf(context),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
widget.label,
|
||
style: EtmTokens.sans(
|
||
size: 11,
|
||
color: EtmTokens.mutedOf(context),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Value tile — horizontal (icon left, text right) ───────────────────────────
|
||
|
||
class _ValueTile extends StatelessWidget {
|
||
final String label;
|
||
final double value;
|
||
final IconData icon;
|
||
final Color color;
|
||
|
||
const _ValueTile({
|
||
required this.label,
|
||
required this.value,
|
||
required this.icon,
|
||
required this.color,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||
boxShadow: EtmTokens.cardShadowOf(context),
|
||
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 30, height: 30,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(9),
|
||
),
|
||
child: Icon(icon, color: color, size: 16),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${value.toStringAsFixed(1)} kWh',
|
||
style: EtmTokens.mono(
|
||
size: 16,
|
||
weight: FontWeight.w600,
|
||
color: EtmTokens.inkOf(context),
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
label,
|
||
style: EtmTokens.sans(
|
||
size: 10.5,
|
||
color: EtmTokens.mutedOf(context),
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|