É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>
245 lines
7.9 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|