etm-powersync-app/lib/widgets/nymea_tile.dart
pakutz79 8862dc2a72 feat: historique énergie, navigation Things, actions nymea
Énergie :
- Écran Énergie reécrit : line chart (production/conso/autoconso/batterie)
  et bar chart (bilan Wh par période) avec onglets 15 min / 1 h / 1 j / 1 sem
- Datepicker pour sélectionner une période historique (chip dismissible)
- Timelines des deux graphiques alignées (même x=i → data[i].timestamp)
- PowerBalanceEntry + fetchPowerBalanceLogs() + simulation sinusoïdale
- Overflow fixes : energy_flow_widget (Expanded sur titre), production_card

Things :
- Navigation 3 niveaux : ThingsScreen → CategoryOverviewScreen → ThingDetailScreen
- Catégorie Cars ajoutée, carrousel corrigé (clamp RangeError)
- ThingDetailScreen : executeAction, setStateValue, activeThumbColor fix
- NymeaTile widget, state_history_chart widget (générique Logging.GetLogEntries)

Modèles / service :
- HistoryEntry, PowerBalanceEntry ajoutés
- fetchHistory(), fetchPowerBalanceLogs() dans NymeaService
- interfaceToCategoryMap étendu (Cars, etc.)
- AppTheme : nouvelles couleurs (accentTeal, boostRed, pvGreen, minPvBlue…)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 07:15:48 +01:00

245 lines
7.9 KiB
Dart

import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
// ─────────────────────────────────────────────────────────────────────────────
// NymeaTile — tuile carrée inspirée de MainPageTile.qml (nymea-app)
//
// Caractéristiques :
// • Fond tileBackground (légèrement teinté, comme nymea)
// • Effet glow animé sur press (BoxShadow colorée → simule Glow{} de Qt)
// • Icône + pastille de statut en haut
// • Valeur principale (grande, colorée) au bas
// • Nom du thing tout en bas
// ─────────────────────────────────────────────────────────────────────────────
class NymeaTile extends StatefulWidget {
/// Widget icône affiché en haut à gauche (généralement un [_TileIcon])
final Widget iconWidget;
/// Nom principal affiché en bas de la tuile
final String title;
/// Valeur d'état primaire (ex. "3.47 kW", "21.5 °C", "On")
final String? primaryValue;
/// Sous-titre optionnel (ex. nom de classe)
final String? subtitle;
/// L'appareil est-il en ligne ? Influence couleur icône et glow
final bool isOnline;
/// Couleur d'accent de la catégorie (utilisée pour l'icône + glow + valeur)
final Color accentColor;
/// Widget optionnel en haut à droite (sinon : pastille de statut)
final Widget? trailing;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const NymeaTile({
super.key,
required this.iconWidget,
required this.title,
this.primaryValue,
this.subtitle,
this.isOnline = true,
this.accentColor = AppTheme.accentTeal,
this.trailing,
this.onTap,
this.onLongPress,
});
@override
State<NymeaTile> createState() => _NymeaTileState();
}
class _NymeaTileState extends State<NymeaTile>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _glow;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
);
_glow = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails _) => _ctrl.forward();
void _onTapUp(TapUpDetails _) {
_ctrl.reverse();
widget.onTap?.call();
}
void _onTapCancel() => _ctrl.reverse();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onLongPress: widget.onLongPress,
child: AnimatedBuilder(
animation: _glow,
builder: (_, child) => Container(
decoration: BoxDecoration(
color: AppTheme.tileBackground,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
boxShadow: [
// Ombre de base (toujours présente)
BoxShadow(
color: Colors.black.withValues(alpha: 0.07),
blurRadius: 6,
offset: const Offset(0, 2),
),
// Glow coloré sur press (simule Glow{} de nymea-app)
BoxShadow(
color: widget.accentColor
.withValues(alpha: 0.45 * _glow.value),
blurRadius: 14 * _glow.value,
spreadRadius: 2 * _glow.value,
),
],
),
child: child,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header : icône + pastille statut ────────────────────────
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.iconWidget,
widget.trailing ??
_StatusDot(
isOnline: widget.isOnline,
color: widget.accentColor,
),
],
),
const Spacer(),
// ── Valeur principale ────────────────────────────────────────
if (widget.primaryValue != null)
Text(
widget.primaryValue!,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: widget.isOnline
? widget.accentColor
: AppTheme.textLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// ── Nom du thing ─────────────────────────────────────────────
Text(
widget.title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textDark,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// ── Sous-titre (classe) ──────────────────────────────────────
if (widget.subtitle != null)
Text(
widget.subtitle!,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
// ─── Pastille de statut ───────────────────────────────────────────────────────
class _StatusDot extends StatelessWidget {
final bool isOnline;
final Color color;
const _StatusDot({required this.isOnline, required this.color});
@override
Widget build(BuildContext context) => Container(
width: 9,
height: 9,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
color: isOnline ? color : Colors.grey[400],
shape: BoxShape.circle,
boxShadow: isOnline
? [
BoxShadow(
color: color.withValues(alpha: 0.5),
blurRadius: 4,
spreadRadius: 1,
)
]
: null,
),
);
}
// ─── TileIcon — icône dans container arrondi (helper) ────────────────────────
/// Icône standard pour une NymeaTile.
/// Usage : `TileIcon(icon: Icons.wb_sunny, color: AppTheme.solarYellow)`
class TileIcon extends StatelessWidget {
final IconData icon;
final Color color;
final bool isOnline;
const TileIcon({
super.key,
required this.icon,
required this.color,
this.isOnline = true,
});
@override
Widget build(BuildContext context) => Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: isOnline ? 0.15 : 0.07),
borderRadius: BorderRadius.circular(9),
),
child: Icon(
icon,
color: isOnline ? color : color.withValues(alpha: 0.4),
size: 22,
),
);
}