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
721 lines
25 KiB
Dart
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', '');
|