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

811 lines
31 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';
// ─────────────────────────────────────────────────────────────────────────────
// ThingDetailScreen — NIVEAU 3
//
// • AppBar : Nom du thing ≡
// • "States" right-aligned + trait coloré
// • Liste plate SANS Cards — lignes séparées par Divider
// - États writables : Switch interactif / bouton edit → setStateValue
// - Actions : dialog de collecte des paramètres → executeAction
// • "Settings" idem, éditables via setThingSettings
// ─────────────────────────────────────────────────────────────────────────────
class ThingDetailScreen extends StatelessWidget {
final NymeaThing thing;
const ThingDetailScreen({super.key, required this.thing});
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final live = service.things.firstWhere(
(t) => t.id == thing.id,
orElse: () => thing,
);
NymeaThingClass? cls;
try {
cls = service.thingClasses.firstWhere((c) => c.id == thing.thingClassId);
} catch (_) {}
final catInfo = cls != null
? categoryInfoMap[cls.category] ?? categoryInfoMap[ThingCategory.other]!
: categoryInfoMap[ThingCategory.other]!;
final stateTypes = cls?.stateTypes ?? [];
final settingTypes = cls?.settingsTypes ?? [];
final actionTypes = cls?.actionTypes ?? [];
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: Text(
live.name,
style: const TextStyle(
color: AppTheme.textDark,
fontWeight: FontWeight.bold,
fontSize: 18),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
actions: [
if (!service.isSimulation)
IconButton(
icon: const Icon(Icons.menu,
color: AppTheme.textDark, size: 22),
onPressed: () =>
_showMenu(context, service, live, catInfo),
),
],
),
body: ListView(
children: [
// ── States ─────────────────────────────────────────────────────────
if (stateTypes.isNotEmpty) ...[
_SectionLabel(label: 'States', accentColor: catInfo.color),
_FlatSection(
children: stateTypes.asMap().entries.map((e) {
final isLast = e.key == stateTypes.length - 1;
final st = e.value;
return _StateRow(
stateType: st,
value: live.stateValue(st.id),
isLast: isLast,
accentColor: catInfo.color,
// onChanged non-null si l'état est writable
onChanged: st.writable
? (newVal) async {
final ok = await service.setStateValue(
live.id, st.id, newVal);
if (!ok && context.mounted) {
_showError(context, 'Modification échouée');
}
}
: null,
);
}).toList(),
),
const SizedBox(height: 20),
],
// ── Settings ───────────────────────────────────────────────────────
if (settingTypes.isNotEmpty) ...[
_SectionLabel(label: 'Settings', accentColor: catInfo.color),
_FlatSection(
children: settingTypes.asMap().entries.map((e) {
final isLast = e.key == settingTypes.length - 1;
final st = e.value;
return _StateRow(
stateType: st,
value: live.settingValue(st.id),
isLast: isLast,
accentColor: catInfo.color,
// Settings sont toujours éditables
onChanged: (newVal) async {
final ok = await service.setThingSettings(
live.id, st.id, newVal);
if (!ok && context.mounted) {
_showError(context, 'Paramètre non appliqué');
}
},
);
}).toList(),
),
const SizedBox(height: 20),
],
// ── Actions ────────────────────────────────────────────────────────
if (actionTypes.isNotEmpty) ...[
_SectionLabel(label: 'Actions', accentColor: catInfo.color),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Wrap(
spacing: 10,
runSpacing: 8,
children: actionTypes
.map((action) => ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: catInfo.color,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.cornerRadius)),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
),
icon: const Icon(Icons.play_arrow_rounded,
size: 18),
label: Text(action.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600)),
onPressed: () => _executeAction(
context, service, live, action,
catInfo.color),
))
.toList(),
),
),
const SizedBox(height: 20),
],
// ── Interfaces ─────────────────────────────────────────────────────
if (cls != null && cls.interfaces.isNotEmpty) ...[
_SectionLabel(label: 'Interfaces', accentColor: catInfo.color),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Wrap(
spacing: 8,
runSpacing: 6,
children: cls.interfaces
.map((iface) => Chip(
label: Text(iface,
style: const TextStyle(fontSize: 12)),
backgroundColor:
catInfo.color.withValues(alpha: 0.1),
side: BorderSide(
color:
catInfo.color.withValues(alpha: 0.3)),
padding: const EdgeInsets.symmetric(
horizontal: 4),
))
.toList(),
),
),
const SizedBox(height: 20),
],
// ── Informations ───────────────────────────────────────────────────
_SectionLabel(label: 'Informations', accentColor: catInfo.color),
_FlatSection(children: [
_InfoRow('ID', live.id, isLast: false),
_InfoRow('Classe', live.thingClassId, isLast: false),
_InfoRow(
'Statut',
live.setupStatus.replaceAll('ThingSetupStatus', ''),
isLast: true),
]),
const SizedBox(height: 60),
],
),
);
}
// ── Exécuter une action ─────────────────────────────────────────────────────
void _executeAction(
BuildContext context,
NymeaService service,
NymeaThing t,
NymeaActionType action,
Color accentColor,
) async {
Map<String, dynamic>? params;
if (action.paramTypes.isEmpty) {
params = {};
} else {
// Collecter les valeurs des paramètres via un dialog
params = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ActionParamDialog(
actionName: action.displayName,
paramTypes: action.paramTypes,
accentColor: accentColor,
),
);
if (params == null) return; // annulé
}
final result = await service.executeAction(
thingId: t.id,
actionTypeId: action.id,
params: params,
);
if (!context.mounted) return;
if (result.success) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Row(children: [
const Icon(Icons.check_circle_outline,
color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(child: Text('${action.displayName} exécutée')),
]),
backgroundColor: accentColor,
duration: const Duration(seconds: 2),
));
} else {
_showError(context, result.errorText);
}
}
// ── Menu ≡ ─────────────────────────────────────────────────────────────────
void _showMenu(BuildContext context, NymeaService service, NymeaThing t,
ThingCategoryInfo info) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (_) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 36, height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 12),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2)),
),
ListTile(
leading: Icon(Icons.edit_rounded, color: info.color),
title: const Text('Renommer'),
onTap: () {
Navigator.pop(context);
_showRenameDialog(context, service, t);
},
),
const Divider(height: 1, color: Color(0xFFEEEEEE)),
ListTile(
leading:
const Icon(Icons.delete_outline_rounded, color: Colors.red),
title: const Text('Supprimer',
style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_showRemoveDialog(context, service, t);
},
),
const SizedBox(height: 8),
]),
),
);
}
void _showRenameDialog(
BuildContext ctx, NymeaService svc, NymeaThing t) {
final ctrl = TextEditingController(text: t.name);
showDialog(
context: ctx,
builder: (c) => AlertDialog(
title: const Text('Renommer'),
content: TextField(
controller: ctrl,
autofocus: true,
decoration:
const InputDecoration(border: OutlineInputBorder()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
onPressed: () async {
final name = ctrl.text.trim();
if (name.isNotEmpty) await svc.renameThing(t.id, name);
if (c.mounted) Navigator.pop(c);
},
child: const Text('Renommer'),
),
],
),
);
}
void _showRemoveDialog(
BuildContext ctx, NymeaService svc, NymeaThing t) {
showDialog(
context: ctx,
builder: (c) => AlertDialog(
title: const Text('Supprimer'),
content: Text('Supprimer "${t.name}" définitivement ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white),
onPressed: () async {
await svc.removeThing(t.id);
if (c.mounted) {
Navigator.pop(c);
Navigator.pop(ctx);
}
},
child: const Text('Supprimer'),
),
],
),
);
}
static void _showError(BuildContext context, String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Row(children: [
const Icon(Icons.error_outline, color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(msg.isEmpty ? 'Erreur' : msg)),
]),
backgroundColor: Colors.redAccent,
duration: const Duration(seconds: 3),
));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _ActionParamDialog — collecte les paramètres d'une action nymea
//
// Construit automatiquement le formulaire selon le type de chaque paramètre :
// • Bool → Switch
// • allowedValues → DropdownButton
// • Int/Double → TextField numérique
// • String → TextField texte
// ─────────────────────────────────────────────────────────────────────────────
class _ActionParamDialog extends StatefulWidget {
final String actionName;
final List<Map<String, dynamic>> paramTypes;
final Color accentColor;
const _ActionParamDialog({
required this.actionName,
required this.paramTypes,
required this.accentColor,
});
@override
State<_ActionParamDialog> createState() => _ActionParamDialogState();
}
class _ActionParamDialogState extends State<_ActionParamDialog> {
late Map<String, dynamic> _values;
late Map<String, TextEditingController> _controllers;
@override
void initState() {
super.initState();
_values = {};
_controllers = {};
for (final p in widget.paramTypes) {
final id = p['id'] as String? ?? '';
final defVal = p['defaultValue'];
_values[id] = defVal;
// Contrôleur texte pour les types non-bool
if ((p['type'] as String? ?? '') != 'Bool') {
_controllers[id] =
TextEditingController(text: defVal?.toString() ?? '');
}
}
}
@override
void dispose() {
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(children: [
Icon(Icons.play_arrow_rounded,
color: widget.accentColor, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text(widget.actionName,
style: const TextStyle(fontSize: 16)),
),
]),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.cornerRadius + 4)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.paramTypes.map(_buildParamWidget).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler')),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: widget.accentColor),
onPressed: () => Navigator.pop(context, _values),
child: const Text('Exécuter'),
),
],
);
}
Widget _buildParamWidget(Map<String, dynamic> param) {
final id = param['id'] as String? ?? '';
final name = param['displayName'] as String?
?? param['name'] as String?
?? id;
final type = param['type'] as String? ?? 'String';
final allowed = (param['allowedValues'] as List?)?.cast<dynamic>();
final unit = param['unit'] as String? ?? '';
// ── Bool ─────────────────────────────────────────────────────────────
if (type == 'Bool') {
final isTrue =
_values[id] == true || _values[id]?.toString() == 'true';
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(name, style: const TextStyle(fontSize: 14)),
value: isTrue,
activeThumbColor: widget.accentColor,
onChanged: (v) => setState(() => _values[id] = v),
);
}
// ── Enum (allowedValues) ──────────────────────────────────────────────
if (allowed != null && allowed.isNotEmpty) {
final current = _values[id]?.toString() ?? allowed.first.toString();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name,
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight)),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[400]!),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButton<String>(
value: current,
underline: const SizedBox(),
isExpanded: true,
items: allowed
.map((v) => DropdownMenuItem<String>(
value: v.toString(),
child: Text(v.toString()),
))
.toList(),
onChanged: (v) => setState(() => _values[id] = v),
),
),
]),
);
}
// ── Int / Double / String ─────────────────────────────────────────────
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: _controllers[id],
decoration: InputDecoration(
labelText: name,
suffixText: unit.isNotEmpty ? unit : null,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: (type == 'Int' || type == 'Double')
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.text,
onChanged: (v) {
if (type == 'Int') {
_values[id] = int.tryParse(v) ?? _values[id];
} else if (type == 'Double') {
_values[id] = double.tryParse(v) ?? _values[id];
} else {
_values[id] = v;
}
},
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _SectionLabel — label right-aligned + trait coloré (style nymea)
// ─────────────────────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String label;
final Color accentColor;
const _SectionLabel({required this.label, required this.accentColor});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(label,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textLight,
fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Container(height: 1.5, color: accentColor),
]),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// _FlatSection — conteneur blanc sans Card
// ─────────────────────────────────────────────────────────────────────────────
class _FlatSection extends StatelessWidget {
final List<Widget> children;
const _FlatSection({required this.children});
@override
Widget build(BuildContext context) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.cardWhite,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
),
child: Column(children: children),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// _StateRow — affiche un état nymea. Writable = interactif.
//
// onChanged != null → état writable (Switch interactif ou bouton edit)
// onChanged == null → read-only
// ─────────────────────────────────────────────────────────────────────────────
class _StateRow extends StatelessWidget {
final NymeaStateType stateType;
final dynamic value;
final bool isLast;
final Color accentColor;
/// Callback appelé avec la nouvelle valeur. Null = read-only.
final Future<void> Function(dynamic)? onChanged;
const _StateRow({
required this.stateType,
required this.value,
required this.isLast,
required this.accentColor,
this.onChanged,
});
bool get _isBool => stateType.type == 'Bool';
bool get _isTrue => value == true || value == 'true';
bool get _isConnected => stateType.name.toLowerCase() == 'connected';
bool get _isWritable => onChanged != null;
@override
Widget build(BuildContext context) {
return Column(children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(children: [
// Nom de l'état
Expanded(
child: Text(stateType.displayName,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textDark,
fontWeight: FontWeight.w400)),
),
// ── Valeur / contrôle ────────────────────────────────────────
if (_isConnected)
// Pastille "Connected" avec glow
Container(
width: 14, height: 14,
decoration: BoxDecoration(
color: _isTrue ? accentColor : Colors.grey[400],
shape: BoxShape.circle,
boxShadow: _isTrue
? [BoxShadow(
color: accentColor.withValues(alpha: 0.4),
blurRadius: 4)]
: null,
),
)
else if (_isBool)
// Switch — interactif si writable
Switch(
value: _isTrue,
activeThumbColor: accentColor,
onChanged: _isWritable ? (v) => onChanged!(v) : null,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
else ...[
// Valeur texte
Text(
stateType.formatValue(value),
style: TextStyle(
fontSize: 14,
color: AppTheme.textDark,
fontWeight: FontWeight.w500),
),
// Bouton edit si writable
if (_isWritable) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () => _showEditDialog(context),
child: Icon(Icons.edit_outlined,
size: 17, color: accentColor.withValues(alpha: 0.7)),
),
],
],
]),
),
if (!isLast)
const Divider(
height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}
/// Dialog d'édition pour les états non-bool writables.
void _showEditDialog(BuildContext context) async {
final allowed = stateType.allowedValues;
// ── Enum (allowedValues) ─────────────────────────────────────────────
if (allowed != null && allowed.isNotEmpty) {
final selected = await showDialog<dynamic>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(stateType.displayName),
children: allowed
.map((v) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, v),
child: Text(v.toString(),
style: TextStyle(
color: v.toString() == value?.toString()
? accentColor
: AppTheme.textDark,
fontWeight:
v.toString() == value?.toString()
? FontWeight.bold
: FontWeight.normal)),
))
.toList(),
),
);
if (selected != null && context.mounted) {
await onChanged!(selected);
}
return;
}
// ── Numérique / texte ────────────────────────────────────────────────
final ctrl = TextEditingController(text: value?.toString() ?? '');
final newVal = await showDialog<dynamic>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(stateType.displayName),
content: TextField(
controller: ctrl,
autofocus: true,
keyboardType: (stateType.type == 'Int' || stateType.type == 'Double')
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.text,
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixText: stateType.unit.isNotEmpty ? stateType.unit : null,
helperText: _rangeHint(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: accentColor),
onPressed: () {
dynamic v = ctrl.text.trim();
if (stateType.type == 'Int') {
v = int.tryParse(ctrl.text.trim()) ?? value;
} else if (stateType.type == 'Double') {
v = double.tryParse(ctrl.text.trim()) ?? value;
}
Navigator.pop(ctx, v);
},
child: const Text('Appliquer'),
),
],
),
);
if (newVal != null && context.mounted) {
await onChanged!(newVal);
}
}
/// Hint de plage min/max pour le TextField.
String? _rangeHint() {
final min = stateType.minValue;
final max = stateType.maxValue;
if (min != null && max != null) {
return 'Entre ${min.toStringAsFixed(0)} et ${max.toStringAsFixed(0)}';
}
if (min != null) return 'Min : ${min.toStringAsFixed(0)}';
if (max != null) return 'Max : ${max.toStringAsFixed(0)}';
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _InfoRow — ligne read-only (ID, classe, statut)
// ─────────────────────────────────────────────────────────────────────────────
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final bool isLast;
const _InfoRow(this.label, this.value, {required this.isLast});
@override
Widget build(BuildContext context) => Column(children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(children: [
Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textLight)),
const SizedBox(width: 16),
Expanded(
child: SelectableText(value,
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textDark)),
),
]),
),
if (!isLast)
const Divider(
height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}