- nymea_models : NymeaVendor + NymeaThingClass étendu (vendorId, setupMethod, createMethods) - nymea_service : getVendors() + getAllThingClasses() (typées, erreurs FR, guards sim/déco) - thing_category : +simpleheatpump/smartgridheatpump -> hvac (interfaces réelles) - thing_catalog_screen : liste groupée + 3 filtres combinables (fabricant/type/recherche) + détail read-only - main.dart : /settings/system/things -> ThingCatalogScreen (remplace le stub) Type dérivé des interfaces réelles (pas d'enum), fabricant résolu par vendorId (doublon ABB géré). Aucun AddThing/Discovery/Pairing (lots B/C à venir). Validé contre nymea réel (hems .75) : 6 vendors, 45 classes, 18 interfaces. Réf. UI_DATA_CONTRACT.md §5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
699 lines
24 KiB
Dart
699 lines
24 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';
|
|
|
|
/// É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.color),
|
|
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),
|
|
// Bouton Ajouter présent mais DÉSACTIVÉ (lot B). Rien câblé.
|
|
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, // lot B — pas de AddThing dans ce lot
|
|
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', '');
|