etm-powersync-app/lib/screens/settings/thing_catalog_screen.dart
Patrick Schurig aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00

721 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/nymea_models.dart';
import '../../models/thing_category.dart';
import '../../providers/installer_mode_provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/etm_tokens.dart';
import 'thing_discovery_screen.dart';
/// Écran **Ajouter un thing** (LECTURE SEULE) — parcourir les thing classes que
/// CE nymea connaît déjà (`GetThingClasses` + `GetVendors`), avec filtres.
///
/// Aucun `AddThing`/`DiscoverThings`/`PairThing` ici (lots B/C). Catégories de
/// type **dérivées des interfaces réelles** (thing_category.dart), pas d'enum
/// inventé. Filtrage 100 % app-side sur les données déjà chargées.
class ThingCatalogScreen extends StatefulWidget {
const ThingCatalogScreen({super.key});
@override
State<ThingCatalogScreen> createState() => _ThingCatalogScreenState();
}
class _ThingCatalogScreenState extends State<ThingCatalogScreen> {
bool _loading = true;
List<NymeaThingClass> _classes = const [];
Map<String, NymeaVendor> _vendorById = const {};
// Filtres (combinables).
String? _vendor; // displayName fabricant
ThingCategory? _category;
String _search = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
if (!mounted) return;
setState(() => _loading = true);
final svc = context.read<NymeaService>();
final classes = await svc.getAllThingClasses();
final vendors = await svc.getVendors();
if (!mounted) return;
setState(() {
_classes = classes;
_vendorById = {for (final v in vendors) v.id: v};
_loading = false;
});
}
String _vendorName(NymeaThingClass c) =>
_vendorById[c.vendorId]?.displayName ?? '(inconnu)';
/// Fabricants réellement présents (dédupliqués par displayName).
List<String> get _vendorOptions {
final set = _classes.map(_vendorName).toSet().toList()..sort();
return set;
}
/// Catégories réellement présentes, dans l'ordre de l'enum.
List<ThingCategory> get _categoryOptions {
final present = _classes.map((c) => c.category).toSet();
return ThingCategory.values.where(present.contains).toList();
}
List<NymeaThingClass> get _filtered {
final q = _norm(_search);
return _classes.where((c) {
if (_vendor != null && _vendorName(c) != _vendor) return false;
if (_category != null && c.category != _category) return false;
if (q.isNotEmpty && !_norm(c.displayName).contains(q)) return false;
return true;
}).toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
}
@override
Widget build(BuildContext context) {
final installer = context.watch<InstallerModeProvider>().isUnlocked;
if (!installer) return const _LockedScaffold();
final filtered = _filtered;
// Regroupement par catégorie pour l'affichage.
final groups = <ThingCategory, List<NymeaThingClass>>{};
for (final c in filtered) {
groups.putIfAbsent(c.category, () => []).add(c);
}
final orderedCats =
ThingCategory.values.where(groups.containsKey).toList();
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: const Text('Ajouter un thing'),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
actions: [
IconButton(
tooltip: 'Rafraîchir',
icon: const Icon(Icons.refresh_rounded),
onPressed: _load,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_FilterBar(
search: _search,
onSearch: (v) => setState(() => _search = v),
vendor: _vendor,
vendors: _vendorOptions,
onVendor: (v) => setState(() => _vendor = v),
category: _category,
categories: _categoryOptions,
onCategory: (c) => setState(() => _category = c),
total: _classes.length,
shown: filtered.length,
),
Expanded(
child: filtered.isEmpty
? const _EmptyResults()
: ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
children: [
for (final cat in orderedCats) ...[
_GroupHeader(categoryInfoMap[cat]!),
for (final c in groups[cat]!)
_ClassTile(
cls: c,
vendorName: _vendorName(c),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ClassDetailScreen(
cls: c, vendorName: _vendorName(c)),
),
),
),
const SizedBox(height: 8),
],
],
),
),
],
),
);
}
}
// ── Normalisation recherche (casse + accents) ───────────────────────────────
String _norm(String s) {
s = s.toLowerCase();
const map = {
'à': 'a', 'â': 'a', 'ä': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e',
'î': 'i', 'ï': 'i', 'ô': 'o', 'ö': 'o', 'ù': 'u', 'û': 'u', 'ü': 'u',
'ç': 'c',
};
map.forEach((k, v) => s = s.replaceAll(k, v));
return s;
}
// ═══════════════════════════════════════════════════════════════════════════
// Barre de filtres
// ═══════════════════════════════════════════════════════════════════════════
class _FilterBar extends StatelessWidget {
final String search;
final ValueChanged<String> onSearch;
final String? vendor;
final List<String> vendors;
final ValueChanged<String?> onVendor;
final ThingCategory? category;
final List<ThingCategory> categories;
final ValueChanged<ThingCategory?> onCategory;
final int total;
final int shown;
const _FilterBar({
required this.search,
required this.onSearch,
required this.vendor,
required this.vendors,
required this.onVendor,
required this.category,
required this.categories,
required this.onCategory,
required this.total,
required this.shown,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
color: EtmTokens.surfaceOf(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
onChanged: onSearch,
style:
EtmTokens.sans(size: 14, color: EtmTokens.inkOf(context)),
decoration: InputDecoration(
hintText: 'Rechercher un modèle…',
prefixIcon: Icon(Icons.search_rounded,
size: 20, color: EtmTokens.faintOf(context)),
isDense: true,
filled: true,
fillColor: EtmTokens.bg2Of(context),
contentPadding: const EdgeInsets.symmetric(vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 10),
_VendorDropdown(
vendor: vendor, vendors: vendors, onVendor: onVendor),
],
),
const SizedBox(height: 8),
SizedBox(
height: 34,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_TypeChip(
label: 'Tous',
selected: category == null,
onTap: () => onCategory(null),
),
for (final cat in categories)
_TypeChip(
label: categoryInfoMap[cat]!.label,
icon: categoryInfoMap[cat]!.icon,
selected: category == cat,
onTap: () => onCategory(category == cat ? null : cat),
),
],
),
),
const SizedBox(height: 4),
Text('$shown / $total modèles',
style:
EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))),
],
),
);
}
}
class _VendorDropdown extends StatelessWidget {
final String? vendor;
final List<String> vendors;
final ValueChanged<String?> onVendor;
const _VendorDropdown(
{required this.vendor, required this.vendors, required this.onVendor});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: EtmTokens.bg2Of(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: vendor,
hint: Text('Fabricant',
style:
EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))),
isDense: true,
items: [
DropdownMenuItem(value: null, child: Text('Tous fabricants')),
for (final v in vendors)
DropdownMenuItem(value: v, child: Text(v)),
],
onChanged: onVendor,
),
),
);
}
}
class _TypeChip extends StatelessWidget {
final String label;
final IconData? icon;
final bool selected;
final VoidCallback onTap;
const _TypeChip(
{required this.label,
this.icon,
required this.selected,
required this.onTap});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? EtmTokens.brand
: EtmTokens.bg2Of(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusPill),
),
child: Row(
children: [
if (icon != null) ...[
Icon(icon,
size: 14,
color: selected ? Colors.white : EtmTokens.mutedOf(context)),
const SizedBox(width: 5),
],
Text(label,
style: EtmTokens.sans(
size: 12.5,
weight: FontWeight.w600,
color: selected
? Colors.white
: EtmTokens.mutedOf(context))),
],
),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Liste
// ═══════════════════════════════════════════════════════════════════════════
class _GroupHeader extends StatelessWidget {
final ThingCategoryInfo info;
const _GroupHeader(this.info);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(2, 14, 0, 8),
child: Row(
children: [
Icon(info.icon, size: 16, color: info.colorOf(context)),
const SizedBox(width: 8),
Text(info.label,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
],
),
);
}
class _ClassTile extends StatelessWidget {
final NymeaThingClass cls;
final String vendorName;
final VoidCallback onTap;
const _ClassTile(
{required this.cls, required this.vendorName, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cls.displayName,
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 2),
Text(vendorName,
style: EtmTokens.sans(
size: 12, color: EtmTokens.mutedOf(context))),
],
),
),
Icon(Icons.chevron_right_rounded,
color: EtmTokens.faintOf(context)),
],
),
),
);
}
}
class _EmptyResults extends StatelessWidget {
const _EmptyResults();
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off_rounded,
size: 36, color: EtmTokens.faintOf(context)),
const SizedBox(height: 12),
Text('Aucun modèle ne correspond',
style: EtmTokens.sans(
size: 14, color: EtmTokens.mutedOf(context))),
],
),
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Détail (lecture seule)
// ═══════════════════════════════════════════════════════════════════════════
class _ClassDetailScreen extends StatelessWidget {
final NymeaThingClass cls;
final String vendorName;
const _ClassDetailScreen({required this.cls, required this.vendorName});
@override
Widget build(BuildContext context) {
final info = cls.categoryDetails;
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: Text(cls.displayName, overflow: TextOverflow.ellipsis),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_Section('Identité', [
_Row('Modèle', cls.displayName),
_Row('Fabricant', vendorName),
_Row('Catégorie', info.label),
_Row('Setup', _setupMethodLabel(cls.setupMethod)),
if (cls.createMethods.isNotEmpty)
_Row('Création',
cls.createMethods.map(_createMethodLabel).join(', ')),
]),
const SizedBox(height: 14),
if (cls.interfaces.isNotEmpty) ...[
_SectionTitle('Interfaces (${cls.interfaces.length})'),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final i in cls.interfaces) _Chip(i)],
),
const SizedBox(height: 14),
],
_SectionTitle('Paramètres requis (${cls.paramTypes.length})'),
const SizedBox(height: 8),
if (cls.paramTypes.isEmpty)
Text('Aucun paramètre.',
style:
EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context)))
else
for (final p in cls.paramTypes) _ParamTile(p),
const SizedBox(height: 20),
// Lot C : discovery+JustAdd → bouton actif « Découvrir ». Sinon (ajout
// direct lot B, ou pairing lot D) → désactivé « bientôt ».
if (cls.isDiscoveryAddable)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: EtmTokens.brand,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ThingDiscoveryScreen(cls: cls),
),
),
icon: const Icon(Icons.search_rounded, color: Colors.white),
label: Text('Découvrir des appareils',
style: EtmTokens.sans(
size: 14, weight: FontWeight.w700, color: Colors.white)),
),
)
else
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: EtmTokens.brand.withValues(alpha: 0.4),
disabledBackgroundColor:
EtmTokens.brand.withValues(alpha: 0.35),
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: null, // ajout direct (lot B) / pairing (lot D)
child: Text('Ajouter · bientôt',
style: EtmTokens.sans(
size: 14, weight: FontWeight.w700, color: Colors.white)),
),
),
],
),
);
}
}
class _ParamTile extends StatelessWidget {
final Map<String, dynamic> p;
const _ParamTile(this.p);
@override
Widget build(BuildContext context) {
final name = (p['displayName'] as String?)?.isNotEmpty == true
? p['displayName'] as String
: (p['name'] as String? ?? '—');
final type = (p['type'] as String?) ?? '';
final unit = _unitLabel((p['unit'] as String?) ?? '');
final def = p['defaultValue'];
final readOnly = p['readOnly'] == true;
final meta = <String>[
if (type.isNotEmpty) type,
if (unit.isNotEmpty) unit,
if (def != null && '$def'.isNotEmpty && '$def' != 'null')
'défaut $def',
if (readOnly) 'lecture seule',
].join(' · ');
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: EtmTokens.sans(
size: 13.5,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
if (meta.isNotEmpty) ...[
const SizedBox(height: 2),
Text(meta,
style: EtmTokens.mono(
size: 11.5, color: EtmTokens.mutedOf(context))),
],
],
),
);
}
}
// ── Petits composants détail ────────────────────────────────────────────────
class _Section extends StatelessWidget {
final String title;
final List<Widget> rows;
const _Section(this.title, this.rows);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: EtmTokens.sans(
size: 12,
weight: FontWeight.w700,
color: EtmTokens.faintOf(context),
letterSpacing: 0.5)),
const SizedBox(height: 6),
...rows,
],
),
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => Text(text,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w700,
color: EtmTokens.inkOf(context)));
}
class _Row extends StatelessWidget {
final String k;
final String v;
const _Row(this.k, this.v);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(k,
style: EtmTokens.sans(
size: 12.5, color: EtmTokens.mutedOf(context))),
),
Expanded(
child: Text(v,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
),
],
),
);
}
class _Chip extends StatelessWidget {
final String text;
const _Chip(this.text);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: EtmTokens.bg2Of(context),
borderRadius: BorderRadius.circular(8),
),
child: Text(text,
style: EtmTokens.mono(
size: 11.5, color: EtmTokens.mutedOf(context))),
);
}
class _LockedScaffold extends StatelessWidget {
const _LockedScaffold();
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: const Text('Ajouter un thing'),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline_rounded,
size: 40, color: EtmTokens.faintOf(context)),
const SizedBox(height: 12),
Text('Réservé au mode installateur',
style: EtmTokens.sans(
size: 14, color: EtmTokens.mutedOf(context))),
],
),
),
),
);
}
// ── Libellés d'enums (lecture seule) ────────────────────────────────────────
String _setupMethodLabel(String v) => switch (v) {
'SetupMethodJustAdd' => 'Ajout direct',
'SetupMethodDisplayPin' => 'PIN affiché sur l\'appareil',
'SetupMethodEnterPin' => 'Saisie d\'un PIN',
'SetupMethodPushButton' => 'Bouton physique',
'SetupMethodUserAndPassword' => 'Identifiant / mot de passe',
'SetupMethodOAuth' => 'OAuth (web)',
'' => '—',
_ => v,
};
String _createMethodLabel(String v) => switch (v) {
'CreateMethodJustAdd' => 'Ajout manuel',
'CreateMethodDiscovery' => 'Découverte',
'CreateMethodAuto' => 'Automatique',
_ => v,
};
String _unitLabel(String v) =>
(v.isEmpty || v == 'UnitNone') ? '' : v.replaceFirst('Unit', '');