etm-powersync-app/lib/screens/things_screen.dart
etm d5dc0c7ca5 Initial commit : Flutter app nymea energy monitor
- NymeaService : auth complète (Hello → Authenticate → SetNotificationStatus)
- Token top-level dans chaque requête JSON-RPC (fix critique GetThings)
- Persistance token via shared_preferences par hôte
- Dashboard : champs utilisateur/mot de passe dans le dialog de connexion
- ThingDetailScreen : renommer, réglages (settingsTypes) et supprimer
- NymeaThingClass : champ settingsTypes parsé depuis l'API
- NymeaThing : copyWith(name) + settingValue()
- Fix overflow _StateChip dans ThingsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:57:46 +01:00

596 lines
22 KiB
Dart

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';
class ThingsScreen extends StatefulWidget {
const ThingsScreen({super.key});
@override
State<ThingsScreen> createState() => _ThingsScreenState();
}
class _ThingsScreenState extends State<ThingsScreen> {
final Set<ThingCategory> _collapsed = {};
String _searchQuery = '';
bool _showSearch = false;
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
// Garder tous les things avec un id valide (le filtre setupStatus était trop strict)
final things = service.things.where((t) => t.id.isNotEmpty).toList();
final thingClasses = service.thingClasses;
final filtered = _searchQuery.isEmpty
? things
: things.where((t) =>
t.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
final Map<ThingCategory, List<NymeaThing>> grouped = {};
for (final thing in filtered) {
final cls = _classFor(thing, thingClasses);
final cat = cls?.category ?? ThingCategory.other;
grouped.putIfAbsent(cat, () => []).add(thing);
}
const orderedCats = [
ThingCategory.energy, ThingCategory.solar, ThingCategory.battery,
ThingCategory.evCharger, ThingCategory.hvac, ThingCategory.lighting,
ThingCategory.sensors, ThingCategory.network, ThingCategory.notifications,
ThingCategory.weather, ThingCategory.media, ThingCategory.other,
];
final cats = orderedCats.where((c) => grouped.containsKey(c)).toList();
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: _buildAppBar(service, things.length),
body: !service.isConnected
? _buildDisconnected()
: (things.isEmpty && !service.thingsLoaded)
? _buildLoading()
: things.isEmpty
? _buildEmpty()
: cats.isEmpty
// Classes pas encore chargées — afficher things en mode simple
? _buildThingsSimple(things, service)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: cats.length,
itemBuilder: (context, i) {
final cat = cats[i];
return _CategorySection(
info: categoryInfoMap[cat]!,
things: grouped[cat]!,
thingClasses: thingClasses,
collapsed: _collapsed.contains(cat),
onToggle: () => setState(() {
if (_collapsed.contains(cat)) {
_collapsed.remove(cat);
} else {
_collapsed.add(cat);
}
}),
onTap: (t) => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ThingDetailScreen(thing: t)),
),
);
},
),
);
}
AppBar _buildAppBar(NymeaService service, int count) {
return AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
title: _showSearch
? TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: 'Rechercher un appareil...',
border: InputBorder.none,
hintStyle: TextStyle(color: AppTheme.textLight),
),
style: const TextStyle(color: AppTheme.textDark, fontSize: 18),
onChanged: (v) => setState(() => _searchQuery = v),
)
: Row(children: [
const Text('Things',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 20)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Text('$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.primaryGreen)),
),
]),
actions: [
IconButton(
icon: Icon(_showSearch ? Icons.close : Icons.search,
color: AppTheme.textDark),
onPressed: () => setState(() {
_showSearch = !_showSearch;
_searchQuery = '';
}),
),
IconButton(
icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark),
onPressed: () => service.refresh(),
),
],
);
}
Widget _buildLoading() => const Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
CircularProgressIndicator(color: AppTheme.primaryGreen),
SizedBox(height: 16),
Text('Chargement des appareils...',
style: TextStyle(color: AppTheme.textLight)),
]),
);
Widget _buildDisconnected() => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.cloud_off_rounded, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
const Text('Non connecté à nymea',
style: TextStyle(color: AppTheme.textLight, fontSize: 16)),
]),
);
/// Affichage simple quand les ThingClasses ne sont pas encore chargées
Widget _buildThingsSimple(List<NymeaThing> things, NymeaService service) {
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
itemCount: things.length,
itemBuilder: (context, i) {
final t = things[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
leading: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.device_hub_rounded,
color: AppTheme.primaryGreen, size: 22),
),
title: Text(t.name,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: Text(t.setupStatus.replaceAll('ThingSetupStatus', ''),
style: const TextStyle(fontSize: 12, color: AppTheme.textLight)),
trailing: t.setupStatus == 'ThingSetupStatusComplete'
? Container(
width: 8, height: 8,
decoration: const BoxDecoration(
color: AppTheme.primaryGreen,
shape: BoxShape.circle,
),
)
: null,
),
);
},
);
}
Widget _buildEmpty() => Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.search_off_rounded, size: 64, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
_searchQuery.isEmpty ? 'Aucun appareil' : 'Aucun résultat pour "$_searchQuery"',
style: const TextStyle(color: AppTheme.textLight, fontSize: 16),
),
]),
);
NymeaThingClass? _classFor(NymeaThing thing, List<NymeaThingClass> classes) {
try { return classes.firstWhere((c) => c.id == thing.thingClassId); }
catch (_) { return null; }
}
}
// ─── Section catégorie ────────────────────────────────────────────────────────
class _CategorySection extends StatelessWidget {
final ThingCategoryInfo info;
final List<NymeaThing> things;
final List<NymeaThingClass> thingClasses;
final bool collapsed;
final VoidCallback onToggle;
final void Function(NymeaThing) onTap;
const _CategorySection({
required this.info, required this.things, required this.thingClasses,
required this.collapsed, required this.onToggle, required this.onTap,
});
NymeaThingClass? _cls(NymeaThing t) {
try { return thingClasses.firstWhere((c) => c.id == t.thingClassId); }
catch (_) { return null; }
}
@override
Widget build(BuildContext context) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// ── Header collapsable ─────────────────────────────────────────────────
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(9),
),
child: Icon(info.icon, color: info.color, size: 19),
),
const SizedBox(width: 10),
Expanded(
child: Text(info.label,
style: const TextStyle(
fontWeight: FontWeight.w700, fontSize: 13,
color: AppTheme.textDark, letterSpacing: 0.2)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text('${things.length}',
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.bold,
color: info.color)),
),
const SizedBox(width: 4),
AnimatedRotation(
turns: collapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more,
color: AppTheme.textLight, size: 20),
),
]),
),
),
// ── Things ────────────────────────────────────────────────────────────
AnimatedCrossFade(
duration: const Duration(milliseconds: 220),
crossFadeState: collapsed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: Column(
children: [
...things.map((t) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ThingCard(
thing: t,
thingClass: _cls(t),
categoryInfo: info,
onTap: () => onTap(t),
),
)),
const SizedBox(height: 4),
],
),
secondChild: const SizedBox.shrink(),
),
]);
}
}
// ─── ThingCard ────────────────────────────────────────────────────────────────
class ThingCard extends StatelessWidget {
final NymeaThing thing;
final NymeaThingClass? thingClass;
final ThingCategoryInfo categoryInfo;
final VoidCallback onTap;
const ThingCard({
super.key, required this.thing, required this.thingClass,
required this.categoryInfo, required this.onTap,
});
@override
Widget build(BuildContext context) {
final primaryType = thingClass?.primaryStateType;
final primaryValue = primaryType?.formatValue(thing.stateValue(primaryType.id));
final secondaryStates = _secondaryStates();
final isOnline = _isOnline();
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Ligne principale
Row(children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: categoryInfo.color.withValues(alpha: isOnline ? 0.15 : 0.07),
borderRadius: BorderRadius.circular(13),
),
child: Icon(categoryInfo.icon,
color: isOnline
? categoryInfo.color
: categoryInfo.color.withValues(alpha: 0.4),
size: 26),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(thing.name,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 15,
color: AppTheme.textDark),
maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(thingClass?.displayName ?? 'Appareil',
style: const TextStyle(fontSize: 12, color: AppTheme.textLight),
maxLines: 1, overflow: TextOverflow.ellipsis),
]),
),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
if (primaryValue != null)
Text(primaryValue,
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 16,
color: isOnline
? categoryInfo.color
: AppTheme.textLight)),
const SizedBox(height: 4),
_StatusDot(isOnline: isOnline),
]),
const SizedBox(width: 2),
_ThingMenu(thing: thing, onDetail: onTap),
]),
// Chips états secondaires
if (secondaryStates.isNotEmpty) ...[
const SizedBox(height: 10),
const Divider(height: 1, color: Color(0xFFEEEEEE)),
const SizedBox(height: 10),
Wrap(
spacing: 7, runSpacing: 6,
children: secondaryStates.map((s) => _StateChip(
label: s.displayName,
value: s.formatValue(thing.stateValue(s.id)),
color: categoryInfo.color,
)).toList(),
),
],
]),
),
),
);
}
bool _isOnline() {
final connType = thingClass?.stateTypes
.where((s) => s.name.toLowerCase() == 'connected');
if (connType != null && connType.isNotEmpty) {
final val = thing.stateValue(connType.first.id);
return val == true || val == 'true';
}
return thing.isSetupComplete;
}
List<NymeaStateType> _secondaryStates() {
if (thingClass == null) return [];
final primary = thingClass!.primaryStateType;
const interesting = [
'voltage', 'current', 'frequency', 'power',
'totalenergyconsumed', 'totalenergyproduced',
'temperature', 'humidity', 'soc',
'chargingmode', 'mode', 'maxchargingcurrent', 'status',
];
return thingClass!.stateTypes
.where((s) =>
s != primary &&
s.name.toLowerCase() != 'connected' &&
interesting.any((i) => s.name.toLowerCase().contains(i)))
.take(3)
.toList();
}
}
// ─── Widgets utilitaires ──────────────────────────────────────────────────────
class _StatusDot extends StatelessWidget {
final bool isOnline;
const _StatusDot({required this.isOnline});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 7, height: 7,
decoration: BoxDecoration(
color: isOnline ? AppTheme.primaryGreen : Colors.grey[400],
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
isOnline ? 'En ligne' : 'Hors ligne',
style: TextStyle(
fontSize: 10,
color: isOnline ? AppTheme.primaryGreen : AppTheme.textLight),
),
]);
}
class _StateChip extends StatelessWidget {
final String label;
final String value;
final Color color;
const _StateChip({required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: Text(label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11, color: color.withValues(alpha: 0.8),
fontWeight: FontWeight.w500)),
),
const SizedBox(width: 5),
Flexible(
child: Text(value,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.bold)),
),
]),
);
}
// ─── Menu contextuel ──────────────────────────────────────────────────────────
class _ThingMenu extends StatelessWidget {
final NymeaThing thing;
final VoidCallback onDetail;
const _ThingMenu({required this.thing, required this.onDetail});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: AppTheme.textLight, size: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
itemBuilder: (_) => [
const PopupMenuItem(value: 'detail',
child: Row(children: [
Icon(Icons.info_outline, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Détails & États'),
])),
const PopupMenuItem(value: 'params',
child: Row(children: [
Icon(Icons.tune, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Paramètres'),
])),
const PopupMenuItem(value: 'rename',
child: Row(children: [
Icon(Icons.edit_outlined, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Renommer'),
])),
const PopupMenuItem(value: 'favorite',
child: Row(children: [
Icon(Icons.star_outline, size: 18, color: AppTheme.textDark),
SizedBox(width: 10), Text('Ajouter aux favoris'),
])),
],
onSelected: (value) {
switch (value) {
case 'detail': onDetail(); break;
case 'params': _showParamsSheet(context); break;
case 'rename': _showRenameDialog(context); break;
case 'favorite':
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('${thing.name} ajouté aux favoris'),
backgroundColor: AppTheme.primaryGreen,
duration: const Duration(seconds: 2),
));
}
},
);
}
void _showParamsSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (_) => Padding(
padding: const EdgeInsets.all(20),
child: Column(mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Icon(Icons.tune, color: AppTheme.primaryGreen),
const SizedBox(width: 8),
Expanded(child: Text('Paramètres — ${thing.name}',
style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 16, color: AppTheme.textDark))),
]),
const SizedBox(height: 4),
SelectableText('ID: ${thing.id}',
style: const TextStyle(fontSize: 10, color: AppTheme.textLight)),
const Divider(height: 24),
if (thing.paramValues.isEmpty)
const Text('Aucun paramètre configuré.',
style: TextStyle(color: AppTheme.textLight))
else
...thing.paramValues.map((p) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(children: [
Expanded(child: Text(p['paramTypeId']?.toString() ?? '?',
style: const TextStyle(color: AppTheme.textLight))),
Text(p['value']?.toString() ?? '',
style: const TextStyle(
fontWeight: FontWeight.w600, color: AppTheme.textDark)),
]),
)),
const SizedBox(height: 20),
]),
),
);
}
void _showRenameDialog(BuildContext context) {
final ctrl = TextEditingController(text: thing.name);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Renommer'),
content: TextField(
controller: ctrl, autofocus: true,
decoration: const InputDecoration(labelText: 'Nouveau nom'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')),
FilledButton(
onPressed: () {
context.read<NymeaService>().renameThing(thing.id, ctrl.text.trim());
Navigator.pop(ctx);
},
child: const Text('Renommer'),
),
],
),
);
}
}