etm-powersync-app/lib/screens/settings/app_settings_screen.dart
Patrick Schurig 7ca42973f2 feat(installateur): ordre de service des charges + locale + ossature i18n
Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).

§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.

§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.

§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).

§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().

§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.

NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.

Écran /energy/loads, gaté installateur (drawer + garde de routeur).

Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 11:34:53 +02:00

177 lines
5.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../providers/installer_mode_provider.dart';
import '../../theme/app_theme.dart';
import '../../theme/etm_theme.dart';
/// Écran racine "App Settings" — liste des catégories.
class AppSettingsScreen extends StatelessWidget {
const AppSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final installer = context.watch<InstallerModeProvider>();
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: const Text('App Settings'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A1A2E),
elevation: 0,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_SettingsGroup(
title: 'PERSONNALISATION',
items: [
_SettingsItem(
icon: Icons.palette_rounded,
label: 'Apparence',
subtitle: 'Thème, couleurs, taille du texte',
onTap: () => context.push('/settings/app/appearance'),
),
_SettingsItem(
icon: Icons.view_list_rounded,
label: 'Écrans actifs',
subtitle: 'Choisir et ordonner les onglets',
onTap: () => context.push('/settings/app/screens'),
),
],
),
const SizedBox(height: 12),
_SettingsGroup(
title: 'INFORMATIONS',
items: [
_SettingsItem(
icon: Icons.info_outline_rounded,
label: 'À propos PowerSync',
subtitle: 'Version, licences',
onTap: () => context.push('/settings/app/about'),
),
],
),
// Configuration système + options développeur : réservés au mode
// installateur (le routeur bloque aussi l'accès direct par URL).
if (installer.isUnlocked) ...[
const SizedBox(height: 12),
_SettingsGroup(
title: 'INSTALLATEUR',
items: [
_SettingsItem(
icon: Icons.account_tree_rounded,
label: 'Rôles & appareils',
subtitle: 'Compteur racine, charges pilotables',
onTap: () => context.push('/energy/setup'),
),
_SettingsItem(
icon: Icons.settings_rounded,
label: 'Configuration système',
subtitle: 'Réseau, protocoles, plugins',
onTap: () => context.push('/settings/system'),
),
_SettingsItem(
icon: Icons.developer_mode_rounded,
label: 'Options développeur',
subtitle: 'Logs verbeux, PIN installateur',
onTap: () => context.push('/settings/app/developer'),
),
],
),
],
],
),
);
}
}
class _SettingsGroup extends StatelessWidget {
final String title;
final List<_SettingsItem> items;
const _SettingsGroup({required this.title, required this.items});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
title,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
color: Color(0xFF6B7280),
),
),
),
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
child: Column(
children: items.asMap().entries.map((e) {
final item = e.value;
final isLast = e.key == items.length - 1;
return Column(
children: [
ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 4),
leading: Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: ETMTheme.accentColor
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon,
color: ETMTheme.accentColor, size: 20),
),
title: Text(item.label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500)),
subtitle: Text(item.subtitle,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textLight)),
trailing: const Icon(Icons.chevron_right_rounded,
color: AppTheme.textLight),
onTap: item.onTap,
),
if (!isLast)
const Divider(
height: 1, indent: 68, endIndent: 16),
],
);
}).toList(),
),
),
],
);
}
}
class _SettingsItem {
final IconData icon;
final String label;
final String subtitle;
final VoidCallback onTap;
const _SettingsItem({
required this.icon,
required this.label,
required this.subtitle,
required this.onTap,
});
}