fix: production détail — onduleurs positionnés gauche/droite comme les consommateurs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd798ae85e
commit
706c390f1f
607
lib/features/dashboard/widgets/energy_flow_card.dart
Normal file
607
lib/features/dashboard/widgets/energy_flow_card.dart
Normal file
@ -0,0 +1,607 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/energy_data.dart';
|
||||
import '../../../theme/etm_tokens.dart';
|
||||
import '../../../shared/widgets/hems_icon.dart';
|
||||
import '../painters/flow_painter.dart';
|
||||
|
||||
enum _DrillView { overview, cons, prod }
|
||||
|
||||
class EnergyFlowCard extends StatefulWidget {
|
||||
final EnergyData data;
|
||||
const EnergyFlowCard({super.key, required this.data});
|
||||
|
||||
@override
|
||||
State<EnergyFlowCard> createState() => _EnergyFlowCardState();
|
||||
}
|
||||
|
||||
class _EnergyFlowCardState extends State<EnergyFlowCard>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _particleCtrl;
|
||||
late final AnimationController _pulseCtrl;
|
||||
late final Animation<double> _pulseAnim;
|
||||
_DrillView _view = _DrillView.overview;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_particleCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2800),
|
||||
)..repeat();
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnim = Tween<double>(begin: 0.22, end: 0.55)
|
||||
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_particleCtrl.dispose();
|
||||
_pulseCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _switchView(_DrillView v) => setState(() => _view = v);
|
||||
|
||||
String get _viewTitle => switch (_view) {
|
||||
_DrillView.overview => 'Flux énergétique',
|
||||
_DrillView.cons => 'Consommation — détail',
|
||||
_DrillView.prod => 'Production — détail',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disableAnim = MediaQuery.of(context).disableAnimations;
|
||||
final eco = EtmTokens.ecoOf(context);
|
||||
final line = EtmTokens.lineOf(context);
|
||||
final surf = EtmTokens.surfaceOf(context);
|
||||
final ink = EtmTokens.inkOf(context);
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: surf,
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
border: Border.all(color: line, width: 1),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Header ─────────────────────────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
if (_view != _DrillView.overview) ...[
|
||||
GestureDetector(
|
||||
onTap: () => _switchView(_DrillView.overview),
|
||||
child: Container(
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: line, width: 1),
|
||||
),
|
||||
child: Icon(Icons.chevron_left_rounded, size: 20, color: ink),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(_viewTitle,
|
||||
style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: ink)),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (context, _) => Container(
|
||||
width: 7, height: 7,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: eco.withValues(alpha: disableAnim ? 0.6 : _pulseAnim.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('en direct',
|
||||
style: EtmTokens.mono(size: 11, color: muted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// ── Flow diagram ────────────────────────────────────────────────────
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth;
|
||||
final segments = _buildSegments(widget.data, _view);
|
||||
return AnimatedBuilder(
|
||||
animation: _particleCtrl,
|
||||
builder: (context, _) {
|
||||
final animVal = disableAnim ? 0.0 : _particleCtrl.value;
|
||||
return Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size(size, size),
|
||||
painter: FlowPainter(
|
||||
segments: segments,
|
||||
animValue: animVal,
|
||||
lineColor: EtmTokens.lineStrongOf(context),
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: ScaleTransition(
|
||||
scale: anim.drive(Tween(begin: 0.88, end: 1.0)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _NodeLayer(
|
||||
key: ValueKey(_view),
|
||||
view: _view,
|
||||
data: widget.data,
|
||||
pulseAnim: disableAnim ? null : _pulseAnim,
|
||||
onTapSolar: () => _switchView(_DrillView.prod),
|
||||
onTapMaison: () => _switchView(_DrillView.cons),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Hint (overview only) ────────────────────────────────────────────
|
||||
if (_view == _DrillView.overview)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: EtmTokens.sans(size: 11, color: muted),
|
||||
children: [
|
||||
const TextSpan(text: 'Touchez '),
|
||||
TextSpan(
|
||||
text: 'Maison',
|
||||
style: EtmTokens.sans(
|
||||
size: 11, weight: FontWeight.w600, color: EtmTokens.brand),
|
||||
),
|
||||
const TextSpan(text: ' ou '),
|
||||
TextSpan(
|
||||
text: 'Solaire',
|
||||
style: EtmTokens.sans(
|
||||
size: 11, weight: FontWeight.w600, color: EtmTokens.solar),
|
||||
),
|
||||
const TextSpan(text: ' pour détailler'),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Edge-to-edge coordinates matching HTML EDGE dict ÷100
|
||||
// nE = 0.22 (satellite inner edge), hE = 0.39 (hub outer edge)
|
||||
static const double _nE = 0.22;
|
||||
static const double _hE = 0.39;
|
||||
|
||||
static List<FlowSegment> _buildSegments(EnergyData d, _DrillView view) {
|
||||
const nE = _nE;
|
||||
const hE = _hE;
|
||||
|
||||
switch (view) {
|
||||
case _DrillView.overview:
|
||||
final gridImport = (-d.gridPower).clamp(0.0, double.infinity);
|
||||
final gridExport = d.gridPower.clamp(0.0, double.infinity);
|
||||
final battCharge = d.batteryPower.clamp(0.0, double.infinity);
|
||||
final battDisch = (-d.batteryPower).clamp(0.0, double.infinity);
|
||||
return [
|
||||
FlowSegment(from: Offset(0.5, nE), to: Offset(0.5, hE), color: EtmTokens.solar, powerW: d.pvPower),
|
||||
FlowSegment(from: Offset(nE, 0.5), to: Offset(hE, 0.5), color: EtmTokens.importColor, powerW: gridImport),
|
||||
FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.solToGrid, powerW: gridExport),
|
||||
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.eco, powerW: battCharge),
|
||||
FlowSegment(from: Offset(1-nE, 0.5), to: Offset(1-hE, 0.5), color: EtmTokens.battToHouse, powerW: battDisch),
|
||||
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.brand, powerW: d.homePower),
|
||||
];
|
||||
|
||||
case _DrillView.cons:
|
||||
final total = d.homePower.clamp(1.0, double.infinity);
|
||||
return [
|
||||
FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: total * 0.38),
|
||||
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18),
|
||||
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: d.chargingPower * 1000),
|
||||
FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.gridColor, powerW: total * 0.22, dashed: true),
|
||||
];
|
||||
|
||||
case _DrillView.prod:
|
||||
final half = d.pvPower / 2;
|
||||
return [
|
||||
FlowSegment(from: Offset(nE, 0.5), to: Offset(hE, 0.5), color: EtmTokens.solar, powerW: half),
|
||||
FlowSegment(from: Offset(1-nE, 0.5), to: Offset(1-hE, 0.5), color: EtmTokens.solar, powerW: half),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Node layer ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _NodeLayer extends StatefulWidget {
|
||||
final _DrillView view;
|
||||
final EnergyData data;
|
||||
final Animation<double>? pulseAnim;
|
||||
final VoidCallback onTapSolar;
|
||||
final VoidCallback onTapMaison;
|
||||
|
||||
const _NodeLayer({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.data,
|
||||
required this.pulseAnim,
|
||||
required this.onTapSolar,
|
||||
required this.onTapMaison,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_NodeLayer> createState() => _NodeLayerState();
|
||||
}
|
||||
|
||||
class _NodeLayerState extends State<_NodeLayer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _enterCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enterCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
)..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_enterCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Animation<double> _stagger(int i) => CurvedAnimation(
|
||||
parent: _enterCtrl,
|
||||
curve: Interval(
|
||||
(i * 0.08).clamp(0.0, 0.6),
|
||||
((i * 0.08) + 0.55).clamp(0.0, 1.0),
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (widget.view) {
|
||||
_DrillView.overview => _buildOverview(context),
|
||||
_DrillView.cons => _buildCons(context),
|
||||
_DrillView.prod => _buildProd(context),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildOverview(BuildContext context) {
|
||||
final d = widget.data;
|
||||
final eco = EtmTokens.ecoOf(context);
|
||||
return Stack(children: [
|
||||
// Hub — Héos with pulsing ring
|
||||
_at(Offset(0.5, 0.5), _buildHub(context, eco)),
|
||||
// Solar (top) — tappable → prod
|
||||
_at(Offset(0.5, 0.12),
|
||||
ScaleTransition(scale: _stagger(0),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTapSolar,
|
||||
child: _sat(context,
|
||||
icon: Icons.wb_sunny_rounded,
|
||||
color: EtmTokens.solarOf(context),
|
||||
label: 'Solaire',
|
||||
value: '${(d.pvPower / 1000).toStringAsFixed(1)} kW',
|
||||
tappable: true,
|
||||
),
|
||||
),
|
||||
)),
|
||||
// Battery (right)
|
||||
_at(Offset(0.88, 0.5),
|
||||
ScaleTransition(scale: _stagger(1),
|
||||
child: _sat(context,
|
||||
icon: Icons.battery_charging_full_rounded,
|
||||
color: eco,
|
||||
label: 'Batterie',
|
||||
value: '${d.batterySOC.toStringAsFixed(0)} %',
|
||||
),
|
||||
)),
|
||||
// Grid (left) — neutral border
|
||||
_at(Offset(0.12, 0.5),
|
||||
ScaleTransition(scale: _stagger(2),
|
||||
child: _sat(context,
|
||||
icon: Icons.home_outlined,
|
||||
color: EtmTokens.gridColor,
|
||||
label: 'Réseau',
|
||||
value: '${(d.gridPower.abs() / 1000).toStringAsFixed(1)} kW',
|
||||
borderColor: EtmTokens.lineOf(context),
|
||||
),
|
||||
)),
|
||||
// Maison (bottom) — tappable → cons
|
||||
_at(Offset(0.5, 0.88),
|
||||
ScaleTransition(scale: _stagger(3),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTapMaison,
|
||||
child: _sat(context,
|
||||
icon: Icons.home_rounded,
|
||||
color: EtmTokens.brand,
|
||||
label: 'Maison',
|
||||
value: '${(d.homePower / 1000).toStringAsFixed(1)} kW',
|
||||
tappable: true,
|
||||
),
|
||||
),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildCons(BuildContext context) {
|
||||
final d = widget.data;
|
||||
final total = d.homePower.clamp(1.0, double.infinity);
|
||||
return Stack(children: [
|
||||
// Hub — Maison
|
||||
_at(Offset(0.5, 0.5),
|
||||
ScaleTransition(scale: _stagger(0),
|
||||
child: _satHub(context,
|
||||
icon: Icons.home_rounded,
|
||||
color: EtmTokens.brand,
|
||||
label: 'Maison',
|
||||
value: '${(d.homePower / 1000).toStringAsFixed(1)} kW',
|
||||
borderColor: EtmTokens.lineOf(context),
|
||||
),
|
||||
)),
|
||||
_at(Offset(0.5, 0.12), ScaleTransition(scale: _stagger(1),
|
||||
child: _sat(context, icon: Icons.heat_pump_outlined, color: EtmTokens.heat,
|
||||
label: 'PAC', value: '${(total * 0.38 / 1000).toStringAsFixed(1)} kW'))),
|
||||
_at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(2),
|
||||
child: _sat(context, icon: Icons.water_drop_outlined, color: EtmTokens.water,
|
||||
label: 'Eau chaude', value: '${(total * 0.18 / 1000).toStringAsFixed(1)} kW'))),
|
||||
_at(Offset(0.5, 0.88), ScaleTransition(scale: _stagger(3),
|
||||
child: _sat(context, icon: Icons.ev_station_rounded, color: eco(context),
|
||||
label: 'Voiture', value: '${d.chargingPower.toStringAsFixed(1)} kW'))),
|
||||
_at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(4),
|
||||
child: _sat(context, icon: Icons.devices_other_rounded, color: EtmTokens.gridColor,
|
||||
label: 'Autres', value: '${(total * 0.22 / 1000).toStringAsFixed(1)} kW',
|
||||
estimated: true))),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildProd(BuildContext context) {
|
||||
final d = widget.data;
|
||||
final solar = EtmTokens.solarOf(context);
|
||||
final half = d.pvPower / 2000;
|
||||
return Stack(children: [
|
||||
_at(Offset(0.5, 0.5),
|
||||
ScaleTransition(scale: _stagger(0),
|
||||
child: _satHub(context,
|
||||
icon: Icons.wb_sunny_rounded,
|
||||
color: solar,
|
||||
label: 'Solaire',
|
||||
value: '${(d.pvPower / 1000).toStringAsFixed(1)} kW',
|
||||
borderColor: EtmTokens.lineOf(context),
|
||||
),
|
||||
)),
|
||||
_at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(1),
|
||||
child: _sat(context, icon: Icons.solar_power_rounded, color: solar,
|
||||
label: 'Onduleur 1', value: '${half.toStringAsFixed(1)} kW', state: _NodeState.ok))),
|
||||
_at(Offset(0.88, 0.5), ScaleTransition(scale: _stagger(2),
|
||||
child: _sat(context, icon: Icons.solar_power_rounded, color: solar,
|
||||
label: 'Onduleur 2', value: '${half.toStringAsFixed(1)} kW', state: _NodeState.ok))),
|
||||
]);
|
||||
}
|
||||
|
||||
Color eco(BuildContext context) => EtmTokens.ecoOf(context);
|
||||
|
||||
// Position any widget by normalized (0..1) coordinates
|
||||
Widget _at(Offset pos, Widget child) => Align(
|
||||
alignment: Alignment(pos.dx * 2 - 1, pos.dy * 2 - 1),
|
||||
child: child,
|
||||
);
|
||||
|
||||
// Hub node — 92px circle, surface bg, line border
|
||||
Widget _satHub(BuildContext context, {
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required String label,
|
||||
required String value,
|
||||
Color? borderColor,
|
||||
}) {
|
||||
final surf = EtmTokens.surfaceOf(context);
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final bdr = borderColor ?? color.withValues(alpha: 0.4);
|
||||
return SizedBox(
|
||||
width: 92, height: 92,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: surf,
|
||||
border: Border.all(color: bdr, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 22),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: EtmTokens.mono(size: 13, weight: FontWeight.w600, color: color)),
|
||||
Text(label, style: EtmTokens.sans(size: 9, color: muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Hub with Héos pulsing ring
|
||||
Widget _buildHub(BuildContext context, Color eco) {
|
||||
final surf = EtmTokens.surfaceOf(context);
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final line = EtmTokens.lineOf(context);
|
||||
return SizedBox(
|
||||
width: 102, height: 102,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Pulsing Héos ring
|
||||
if (widget.pulseAnim != null)
|
||||
AnimatedBuilder(
|
||||
animation: widget.pulseAnim!,
|
||||
builder: (context, _) => Container(
|
||||
width: 102, height: 102,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: eco.withValues(alpha: widget.pulseAnim!.value),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Hub circle — Héos
|
||||
Container(
|
||||
width: 92, height: 92,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: surf,
|
||||
border: Border.all(color: line, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
HemsIcon(size: 24, color: eco),
|
||||
const SizedBox(height: 3),
|
||||
Text('Héos', style: EtmTokens.sans(size: 9, color: muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Satellite node — 74px circle, content inside
|
||||
Widget _sat(BuildContext context, {
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required String label,
|
||||
required String value,
|
||||
Color? borderColor,
|
||||
bool tappable = false,
|
||||
bool estimated = false,
|
||||
_NodeState? state,
|
||||
}) {
|
||||
const size = 74.0;
|
||||
final surf = EtmTokens.surfaceOf(context);
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
final bdr = borderColor ?? color.withValues(alpha: 0.4);
|
||||
|
||||
Widget circle = Container(
|
||||
width: size, height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: surf,
|
||||
border: Border.all(
|
||||
color: estimated ? Colors.transparent : bdr,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 21),
|
||||
const SizedBox(height: 1),
|
||||
Text(value, style: EtmTokens.mono(size: 11, weight: FontWeight.w600, color: color)),
|
||||
Text(label, style: EtmTokens.sans(size: 8.5, color: muted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (estimated) {
|
||||
circle = CustomPaint(
|
||||
painter: _DashedBorderPainter(color: color, radius: size / 2),
|
||||
child: circle,
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: size, height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
circle,
|
||||
if (tappable)
|
||||
Positioned(
|
||||
right: 2, top: 2,
|
||||
child: Container(
|
||||
width: 15, height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: surf, width: 1),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.add, size: 9, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state != null)
|
||||
Positioned(
|
||||
right: 4, bottom: 4,
|
||||
child: Container(
|
||||
width: 7, height: 7,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: state == _NodeState.ok ? EtmTokens.eco : EtmTokens.heat,
|
||||
border: Border.all(color: surf, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _NodeState { ok }
|
||||
|
||||
// ── Dashed border painter ─────────────────────────────────────────────────────
|
||||
|
||||
class _DashedBorderPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double radius;
|
||||
const _DashedBorderPainter({required this.color, required this.radius});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const dashCount = 16;
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: 0.4)
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke;
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
const step = 3.14159 * 2 / dashCount;
|
||||
for (int i = 0; i < dashCount; i += 2) {
|
||||
final start = i * step;
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
start,
|
||||
step * 0.6,
|
||||
false,
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DashedBorderPainter old) => old.color != color;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user