etm-powersync-app/lib/screens/category_overview_screen.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

273 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/nymea_models.dart';
import '../models/thing_category.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
import 'thing_detail_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// CategoryOverviewScreen — NIVEAU 2
//
// Liste des things d'une catégorie.
// Reproduit smart-meters_overview.png (nymea-app) :
//
// AppBar : [NOM CATÉGORIE] (centré)
//
// Pour chaque thing :
// ┌─────────────────────────────────────┐
// │ Nom du thing (bold) ● │ ← fond coloré léger
// ├─────────────────────────────────────┤
// │ [icône] Classe appareil valeur ›│ ← fond blanc
// └─────────────────────────────────────┘
// ─────────────────────────────────────────────────────────────────────────────
class CategoryOverviewScreen extends StatelessWidget {
final ThingCategoryInfo info;
final List<NymeaThing> things;
final List<NymeaThingClass> thingClasses;
const CategoryOverviewScreen({
super.key,
required this.info,
required this.things,
required this.thingClasses,
});
// ── Helpers ────────────────────────────────────────────────────────────────
NymeaThingClass? _cls(NymeaThing t) {
try {
return thingClasses.firstWhere((c) => c.id == t.thingClassId);
} catch (_) {
return null;
}
}
bool _isOnline(NymeaThing t, NymeaThingClass? cls) {
final ct =
cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected');
if (ct != null && ct.isNotEmpty) {
final v = t.stateValue(ct.first.id);
return v == true || v == 'true';
}
return t.isSetupComplete;
}
String? _primaryValue(NymeaThing t, NymeaThingClass? cls) {
final p = cls?.primaryStateType;
if (p == null) return null;
return p.formatValue(t.stateValue(p.id));
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
// Écoute les mises à jour live via le service
final service = context.watch<NymeaService>();
final liveThings = things.map((t) {
try {
return service.things.firstWhere((lt) => lt.id == t.id);
} catch (_) {
return t;
}
}).toList();
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.chevron_left,
color: AppTheme.textDark, size: 30),
onPressed: () => Navigator.pop(context),
),
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(info.icon, color: info.color, size: 20),
const SizedBox(width: 8),
Text(
info.label,
style: const TextStyle(
color: AppTheme.textDark,
fontWeight: FontWeight.bold,
fontSize: 18),
),
],
),
),
body: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 60),
itemCount: liveThings.length,
separatorBuilder: (context, i) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final t = liveThings[i];
final cls = _cls(t);
return _ThingListCard(
thing: t,
thingClass: cls,
info: info,
isOnline: _isOnline(t, cls),
primaryValue: _primaryValue(t, cls),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => ThingDetailScreen(thing: t)),
),
);
},
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _ThingListCard — Card deux lignes style nymea
// ─────────────────────────────────────────────────────────────────────────────
class _ThingListCard extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
final ThingCategoryInfo info;
final bool isOnline;
final String? primaryValue;
final VoidCallback onTap;
const _ThingListCard({
required this.thing,
required this.thingClass,
required this.info,
required this.isOnline,
required this.primaryValue,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
border: Border.all(
color: info.color.withValues(alpha: 0.28), width: 1),
),
child: Column(
children: [
// ── Ligne 1 : nom du thing ─────────────────────────────────
Container(
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.10),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(9)),
),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 11),
child: Row(
children: [
Expanded(
child: Text(
thing.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textDark),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Pastille statut
Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color:
isOnline ? info.color : Colors.grey[400],
shape: BoxShape.circle,
boxShadow: isOnline
? [
BoxShadow(
color: info.color
.withValues(alpha: 0.45),
blurRadius: 4,
)
]
: null,
),
),
],
),
),
// ── Ligne 2 : icône + classe + valeur primaire ─────────────
Container(
color: AppTheme.cardWhite,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: Row(
children: [
// Icône
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: info.color
.withValues(alpha: isOnline ? 0.12 : 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
info.icon,
size: 18,
color: isOnline
? info.color
: info.color.withValues(alpha: 0.4),
),
),
const SizedBox(width: 12),
// Classe appareil
Expanded(
child: Text(
thingClass?.displayName ?? 'Appareil',
style: const TextStyle(
fontSize: 13, color: AppTheme.textLight),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Valeur primaire
Text(
primaryValue ?? '',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isOnline
? info.color
: AppTheme.textLight,
),
),
const SizedBox(width: 4),
// Flèche
const Icon(Icons.chevron_right,
size: 18, color: AppTheme.textLight),
],
),
),
],
),
),
),
),
);
}
}