import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../main.dart' show DrawerMenuButton; import '../models/energy_data.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; import '../widgets/energy_flow_widget.dart'; import '../widgets/ev_charging_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @override State createState() => _DashboardScreenState(); } class _DashboardScreenState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { final service = context.read(); if (!service.connected) service.startSimulation(); }); } @override Widget build(BuildContext context) { return Consumer( builder: (context, service, _) { final data = service.energyData; return Scaffold( backgroundColor: EtmTokens.bg, body: RefreshIndicator( onRefresh: () async => service.startSimulation(), color: EtmTokens.green, child: CustomScrollView( slivers: [ _DashAppBar(service: service), SliverPadding( padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), sliver: SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: 8), _SystemHeroCard(data: data), const SizedBox(height: 16), EnergyFlowWidget(data: data), const SizedBox(height: 16), EVChargingCard(data: data, service: service), const SizedBox(height: 16), _KpiGrid(data: data), const SizedBox(height: 16), _ConsumersCard(data: data), const SizedBox(height: 16), _HeosDecisionsCard(), const SizedBox(height: 16), _ForecastCard(), ]), ), ), ], ), ), ); }, ); } } // ─────────────────────────────── AppBar ──────────────────────────────────────── class _DashAppBar extends StatelessWidget { final NymeaService service; const _DashAppBar({required this.service}); @override Widget build(BuildContext context) { return SliverAppBar( floating: true, backgroundColor: EtmTokens.bg, elevation: 0, leading: const DrawerMenuButton(), leadingWidth: 64, title: Text('ETM PowerSync', style: EtmTokens.sans(size: 20, weight: FontWeight.w600)), actions: [ IconButton( icon: Icon( service.connected ? Icons.wifi_rounded : Icons.wifi_off_rounded, color: service.connected ? EtmTokens.green : EtmTokens.danger, ), onPressed: () => _showConnectionDialog(context, service), ), Padding( padding: const EdgeInsets.only(right: 16), child: Stack( clipBehavior: Clip.none, children: [ const Icon(Icons.notifications_outlined, color: EtmTokens.navy, size: 24), Positioned( right: -1, top: -1, child: Container( width: 8, height: 8, decoration: BoxDecoration( color: EtmTokens.green, shape: BoxShape.circle, border: Border.all(color: EtmTokens.bg, width: 1.5), ), ), ), ], ), ), ], ); } void _showConnectionDialog(BuildContext context, NymeaService service) { showDialog( context: context, builder: (_) => AlertDialog( title: Text('Connexion nymea', style: EtmTokens.sans(size: 18, weight: FontWeight.w600)), content: Text( service.connected ? 'Connecté à ${service.host}' : service.isSimulation ? 'Mode simulation actif' : 'Non connecté', style: EtmTokens.sans(size: 14, color: EtmTokens.muted), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('OK', style: EtmTokens.sans(color: EtmTokens.green)), ), ], ), ); } } // ─────────────────────────── Hero système ────────────────────────────────────── class _SystemHeroCard extends StatelessWidget { final EnergyData data; const _SystemHeroCard({required this.data}); String get _statusLabel { if (data.gridPower.abs() < 50) return 'Optimal'; return data.gridPower > 0 ? 'Soutirage réseau' : 'Injection réseau'; } Color get _statusColor { if (data.gridPower.abs() < 50) return EtmTokens.green; return data.gridPower > 0 ? EtmTokens.orange : EtmTokens.blue; } @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: EtmTokens.card, borderRadius: BorderRadius.circular(EtmTokens.radiusLg), boxShadow: EtmTokens.cardShadow, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Left — status + metrics Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(22, 22, 12, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _StatusPill(label: _statusLabel, color: _statusColor), const SizedBox(width: 10), Text('État du système', style: EtmTokens.sans( size: 11, color: EtmTokens.faint, letterSpacing: 0.12)), ], ), const SizedBox(height: 12), Text.rich( TextSpan( text: 'Maison ', style: EtmTokens.sans(size: 17, weight: FontWeight.w500), children: [ TextSpan( text: '${data.selfConsumptionRate.toStringAsFixed(0)}%', style: EtmTokens.sans(size: 17, weight: FontWeight.w700), ), const TextSpan(text: ' autonome'), ], ), ), const SizedBox(height: 4), Text( '${data.gridPower.abs().toStringAsFixed(0)} W soutiré du réseau', style: EtmTokens.sans(size: 13, color: EtmTokens.muted), ), const SizedBox(height: 16), Row( children: [ _Metric(label: 'Autocons.', value: '${data.selfConsumptionRate.toStringAsFixed(0)}%'), const SizedBox(width: 20), _Metric(label: 'Soutirage', value: '${data.gridPower.abs().toStringAsFixed(0)} W'), const SizedBox(width: 20), _Metric(label: 'Batterie', value: '${data.batterySOC.toStringAsFixed(0)}%'), ], ), ], ), ), ), // Right — illustration maison Container( width: 120, decoration: const BoxDecoration( border: Border(left: BorderSide(color: EtmTokens.line)), borderRadius: BorderRadius.only( topRight: Radius.circular(EtmTokens.radiusLg), bottomRight: Radius.circular(EtmTokens.radiusLg), ), ), child: const _HouseIllustration(), ), ], ), ); } } class _StatusPill extends StatelessWidget { final String label; final Color color; const _StatusPill({required this.label, required this.color}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(99), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 5), Text(label, style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: color)), ], ), ); } } class _Metric extends StatelessWidget { final String label; final String value; const _Metric({required this.label, required this.value}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), Text(value, style: EtmTokens.mono(size: 18, weight: FontWeight.w700)), ], ); } } // Illustration maison simplifiée en CustomPainter class _HouseIllustration extends StatelessWidget { const _HouseIllustration(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(10), child: AspectRatio( aspectRatio: 270 / 200, child: CustomPaint(painter: _HousePainter()), ), ); } } class _HousePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final sx = size.width / 270; final sy = size.height / 200; Offset p(double x, double y) => Offset(x * sx, y * sy); final outlinePaint = Paint() ..color = const Color(0xFF0D2B3B).withValues(alpha: 0.45) ..strokeWidth = 1.0 ..style = PaintingStyle.stroke; // Ground canvas.drawOval(Rect.fromCenter(center: p(138, 176), width: 236 * sx, height: 24 * sy), Paint()..color = const Color(0xFFE9EEF1)); // Front wall canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), Paint()..color = const Color(0xFFF6F9FB)); canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), outlinePaint); // Side wall final side = Path() ..moveTo(p(170, 96).dx, p(170, 96).dy) ..lineTo(p(200, 84).dx, p(200, 84).dy) ..lineTo(p(200, 158).dx, p(200, 158).dy) ..lineTo(p(170, 168).dx, p(170, 168).dy)..close(); canvas.drawPath(side, Paint()..color = const Color(0xFFDDE6EC)); canvas.drawPath(side, outlinePaint); // Roof front slope final roofF = Path() ..moveTo(p(58, 98).dx, p(58, 98).dy) ..lineTo(p(118, 56).dx, p(118, 56).dy) ..lineTo(p(170, 56).dx, p(170, 56).dy) ..lineTo(p(122, 98).dx, p(122, 98).dy)..close(); canvas.drawPath(roofF, Paint()..color = const Color(0xFF3A4A55)); canvas.drawPath(roofF, outlinePaint); // PV panels final pv = Path() ..moveTo(p(70, 92).dx, p(70, 92).dy) ..lineTo(p(120, 60).dx, p(120, 60).dy) ..lineTo(p(162, 60).dx, p(162, 60).dy) ..lineTo(p(116, 92).dx, p(116, 92).dy)..close(); canvas.drawPath(pv, Paint()..color = const Color(0xFF173A52)); // Amber sheen canvas.drawPath(pv, Paint()..color = const Color(0xFFFEC113).withValues(alpha: 0.10)..style = PaintingStyle.fill); // Wallbox (blue) canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy), const Radius.circular(3)), Paint()..color = const Color(0xFF31A3DD), ); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy), const Radius.circular(3)), outlinePaint, ); // Door canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(p(86, 128).dx, p(86, 128).dy, 22 * sx, 40 * sy), const Radius.circular(2)), Paint()..color = const Color(0xFFC98A4A), ); // Window canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy), const Radius.circular(2)), Paint()..color = const Color(0xFFBFE2F3), ); canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy), const Radius.circular(2)), outlinePaint, ); // Labels void drawLabel(String text, Offset pos) { final tp = TextPainter( text: TextSpan( text: text, style: const TextStyle( fontSize: 7, fontWeight: FontWeight.w600, color: Color(0xFF6B7D88), letterSpacing: 0.5), ), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, pos); } drawLabel('PV', p(150, 32)); drawLabel('BORNE', p(34, 127)); } @override bool shouldRepaint(_HousePainter _) => false; } // ─────────────────────────── KPI 2×2 ────────────────────────────────────────── class _KpiGrid extends StatelessWidget { final EnergyData data; const _KpiGrid({required this.data}); @override Widget build(BuildContext context) { final prodKwh = (data.dayProductionWh / 1000); return Column( children: [ Row( children: [ Expanded(child: _KpiCard( title: 'Production', value: prodKwh.toStringAsFixed(1), unit: ' kWh', chart: _SparkBars(values: const [18, 12, 15, 22, 40, 55, 70, 85, 100, 78, 60, 42]), )), const SizedBox(width: 14), Expanded(child: _KpiCard( title: 'Autonomie', value: data.autonomyRate.toStringAsFixed(0), unit: '%', color: EtmTokens.blue, chart: _ProgressBar(value: data.autonomyRate / 100, color: EtmTokens.blue), note: 'Objectif : 80%', )), ], ), const SizedBox(height: 14), Row( children: [ Expanded(child: _KpiCard( title: 'Économie', value: data.dayGains.toStringAsFixed(2), unit: ' €', color: EtmTokens.green, chart: _TrendLine(), note: 'vs maison non pilotée', )), const SizedBox(width: 14), Expanded(child: _KpiCard( title: 'Soutirage', value: (data.dayGridInjectionWh.abs() / 1000).toStringAsFixed(1), unit: ' kWh', chart: _SparkBars( values: const [30, 55, 40, 20, 8, 5, 6, 10], color: EtmTokens.faint), note: '0.42 €', )), ], ), ], ); } } class _KpiCard extends StatelessWidget { final String title; final String value; final String unit; final Color color; final Widget chart; final String? note; const _KpiCard({ required this.title, required this.value, required this.unit, this.color = EtmTokens.navy, required this.chart, this.note, }); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: EtmTokens.card, borderRadius: BorderRadius.circular(EtmTokens.radius), boxShadow: EtmTokens.cardShadow, ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w500)), const SizedBox(height: 6), Text.rich( TextSpan( text: value, style: EtmTokens.mono(size: 26, weight: FontWeight.w700, color: color), children: [ TextSpan( text: unit, style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w600), ), ], ), ), const SizedBox(height: 8), chart, if (note != null) ...[ const SizedBox(height: 6), Text(note!, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), ], ], ), ); } } class _SparkBars extends StatelessWidget { final List values; final Color color; const _SparkBars({required this.values, this.color = EtmTokens.amber}); @override Widget build(BuildContext context) { final max = values.reduce((a, b) => a > b ? a : b); return SizedBox( height: 34, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: values.map((v) => Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: FractionallySizedBox( heightFactor: v / max, alignment: Alignment.bottomCenter, child: DecoratedBox( decoration: BoxDecoration( color: color.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(2), ), ), ), ), )).toList(), ), ); } } class _ProgressBar extends StatelessWidget { final double value; final Color color; const _ProgressBar({required this.value, required this.color}); @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(99), child: SizedBox( height: 7, child: LinearProgressIndicator( value: value, backgroundColor: EtmTokens.line, valueColor: AlwaysStoppedAnimation(color), ), ), ); } } class _TrendLine extends StatelessWidget { const _TrendLine(); @override Widget build(BuildContext context) { return SizedBox( height: 38, child: CustomPaint(painter: _TrendPainter()), ); } } class _TrendPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final points = [34.0, 30.0, 31.0, 24.0, 22.0, 14.0, 11.0, 3.0]; final w = size.width; final h = size.height; final dx = w / (points.length - 1); final max = points.reduce((a, b) => a > b ? a : b); final pts = List.generate(points.length, (i) => Offset(i * dx, h - (points[i] / max) * (h - 4))); // Fill final fill = Path()..moveTo(pts.first.dx, h); for (final p in pts) { fill.lineTo(p.dx, p.dy); } fill.lineTo(pts.last.dx, h); fill.close(); canvas.drawPath( fill, Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ EtmTokens.green.withValues(alpha: 0.25), EtmTokens.green.withValues(alpha: 0), ], ).createShader(Rect.fromLTWH(0, 0, w, h))); // Line final linePaint = Paint() ..color = EtmTokens.green ..strokeWidth = 2.2 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; final path = Path()..moveTo(pts.first.dx, pts.first.dy); for (final p in pts.skip(1)) { path.lineTo(p.dx, p.dy); } canvas.drawPath(path, linePaint); } @override bool shouldRepaint(_TrendPainter _) => false; } // ─────────────────────────── Consommateurs ───────────────────────────────────── class _ConsumersCard extends StatelessWidget { final EnergyData data; const _ConsumersCard({required this.data}); @override Widget build(BuildContext context) { final total = data.homePower.clamp(1.0, double.infinity); final consumers = [ _Consumer('PAC', Icons.heat_pump_outlined, EtmTokens.blue, EtmTokens.blueSoft, total * 0.40), _Consumer('EVSE', Icons.ev_station_rounded, EtmTokens.green, EtmTokens.greenSoft, data.chargingPower * 1000), _Consumer('Chauffe-eau', Icons.water_drop_outlined, EtmTokens.amber, EtmTokens.amberSoft, total * 0.18), ]; return _Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _CardHeader('Consommateurs principaux', link: 'Voir tout'), const SizedBox(height: 4), ...consumers.map((c) => _ConsumerRow(c: c, total: total)), ], ), ); } } class _Consumer { final String name; final IconData icon; final Color color; final Color bgColor; final double watts; const _Consumer(this.name, this.icon, this.color, this.bgColor, this.watts); } class _ConsumerRow extends StatelessWidget { final _Consumer c; final double total; const _ConsumerRow({required this.c, required this.total}); @override Widget build(BuildContext context) { final pct = (c.watts / total).clamp(0.0, 1.0); return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration(color: c.bgColor, borderRadius: BorderRadius.circular(10)), child: Icon(c.icon, color: c.color, size: 18), ), const SizedBox(width: 12), Expanded(child: Text(c.name, style: EtmTokens.sans(size: 14, weight: FontWeight.w500))), Text('${c.watts.toStringAsFixed(0)} W', style: EtmTokens.mono(size: 13, color: EtmTokens.muted)), const SizedBox(width: 12), SizedBox( width: 70, child: ClipRRect( borderRadius: BorderRadius.circular(99), child: SizedBox( height: 6, child: LinearProgressIndicator( value: pct, backgroundColor: EtmTokens.line, valueColor: AlwaysStoppedAnimation(c.color), ), ), ), ), const SizedBox(width: 8), SizedBox( width: 36, child: Text('${(pct * 100).toStringAsFixed(0)}%', style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: c.color), textAlign: TextAlign.right), ), ], ), ); } } // ─────────────────────────── Décisions Héos ──────────────────────────────────── class _HeosDecisionsCard extends StatelessWidget { const _HeosDecisionsCard(); @override Widget build(BuildContext context) { return _Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _CardHeader('Décisions d\'Héos', link: 'Voir tout'), const SizedBox(height: 4), _DecisionRow( icon: Icons.ev_station_rounded, bgColor: EtmTokens.greenSoft, iconColor: EtmTokens.green, title: 'Charge voiture décalée', time: '02h – 06h', tags: [_Tag.tarif, _Tag.pv], ), _DecisionRow( icon: Icons.heat_pump_outlined, bgColor: EtmTokens.blueSoft, iconColor: EtmTokens.blue, title: 'PAC optimisée', time: '06h30 – 08h30', tags: [_Tag.heuresCreuses, _Tag.confort], ), _DecisionRow( icon: Icons.battery_charging_full_rounded, bgColor: EtmTokens.greenSoft, iconColor: EtmTokens.green, title: 'Charge batterie planifiée', time: '12h – 15h', tags: [_Tag.pv, _Tag.pic], ), Padding( padding: const EdgeInsets.only(top: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Impact consolidé aujourd\'hui', style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), Text.rich(TextSpan( text: '4,60 € ', style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: EtmTokens.greenDark), children: [ TextSpan(text: 'économisés', style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), ], )), ], ), ), ], ), ); } } enum _Tag { tarif, pv, heuresCreuses, confort, pic } class _DecisionRow extends StatelessWidget { final IconData icon; final Color bgColor; final Color iconColor; final String title; final String time; final List<_Tag> tags; const _DecisionRow({ required this.icon, required this.bgColor, required this.iconColor, required this.title, required this.time, required this.tags, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 34, height: 34, decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)), child: Icon(icon, color: iconColor, size: 18), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Text(time, style: EtmTokens.mono(size: 12, color: EtmTokens.muted)), const SizedBox(height: 6), Wrap( spacing: 5, runSpacing: 5, children: tags.map(_buildTag).toList(), ), ], ), ), ], ), ); } Widget _buildTag(_Tag tag) { final (label, bg, fg) = switch (tag) { _Tag.tarif => ('Tarif bas', EtmTokens.amberSoft, const Color(0xFF9A7510)), _Tag.pv => ('Surplus PV', EtmTokens.greenSoft, EtmTokens.greenDark), _Tag.heuresCreuses=> ('Heures creuses',EtmTokens.amberSoft, const Color(0xFF9A7510)), _Tag.confort => ('Confort', const Color(0xFFEEF2F5), EtmTokens.muted), _Tag.pic => ('Couvre le pic', EtmTokens.blueSoft, const Color(0xFF1F6F97)), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)), child: Text(label, style: EtmTokens.sans(size: 11, weight: FontWeight.w500, color: fg)), ); } } // ─────────────────────────── Prévisions ──────────────────────────────────────── class _ForecastCard extends StatelessWidget { const _ForecastCard(); @override Widget build(BuildContext context) { return _Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Prévisions & recommandations', style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), Text('aujourd\'hui', style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), ], ), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: EtmTokens.bg, borderRadius: BorderRadius.circular(14), ), child: Row( children: [ Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 32), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Prévisions non disponibles', style: EtmTokens.sans(size: 14, weight: FontWeight.w500, color: EtmTokens.muted)), const SizedBox(height: 4), Text('Configurez un provider dans Tarifs & Héos pour activer les prévisions PV et tarifaires.', style: EtmTokens.sans(size: 12, color: EtmTokens.faint)), ], ), ), ], ), ), ], ), ); } } // ─────────────────────────── Widgets partagés ────────────────────────────────── class _Card extends StatelessWidget { final Widget child; final EdgeInsets? padding; const _Card({required this.child, this.padding}); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: EtmTokens.card, borderRadius: BorderRadius.circular(EtmTokens.radiusLg), boxShadow: EtmTokens.cardShadow, ), padding: padding ?? const EdgeInsets.all(20), child: child, ); } } class _CardHeader extends StatelessWidget { final String title; final String? link; const _CardHeader(this.title, {this.link}); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(title, style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), if (link != null) Text(link!, style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: EtmTokens.blue)), ], ); } }