etm-powersync-app/lib/screens/energy/role_config_flow.dart
pakutz79 c19c9d1a98 feat: navigation drawer, EMS setup, scheduler, tarifs, paramètres app
- Drawer custom (overlay Stack) avec mode installateur PIN SHA-256
- GoRouter + ShellRoute : navigation préservée entre onglets
- 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider,
  EnergySetupProvider, SchedulerProvider, TariffProvider
- Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline
- Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen
- DrawerMenuButton présent dans les 5 AppBars principaux
- Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles
- Compteur solaire : ajout smartmeter aux interfaces compatibles
- Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard
- Dépendances : go_router, shared_preferences, crypto, url_launcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:52:32 +01:00

689 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/nymea_models.dart';
import '../../providers/energy_setup_provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/etm_theme.dart';
/// Bottom sheet en 3 étapes pour configurer un rôle EMS.
///
/// Étape 1 : Choisir un thing compatible
/// Étape 2 : Paramètres spécifiques au rôle
/// Étape 3 : Test de connexion
class RoleConfigFlow extends StatefulWidget {
final EmsRole role;
final RoleAssignment? existing;
const RoleConfigFlow({
super.key,
required this.role,
this.existing,
});
@override
State<RoleConfigFlow> createState() => _RoleConfigFlowState();
}
class _RoleConfigFlowState extends State<RoleConfigFlow> {
int _step = 0;
NymeaThing? _selectedThing;
String _searchQuery = '';
final Map<String, dynamic> _params = {};
// Paramètres par défaut selon le rôle
void _initParams() {
switch (widget.role) {
case EmsRole.evCharger:
_params['phases'] = 1;
_params['minA'] = 6;
_params['maxA'] = 32;
_params['priority'] = 'Normal';
case EmsRole.dhw:
_params['powerW'] = 2000;
_params['priority'] = 'Normal';
case EmsRole.heatPump:
_params['powerW'] = 5000;
_params['priority'] = 'High';
case EmsRole.battery:
_params['capacityWh'] = 10000;
_params['maxChargeW'] = 3000;
_params['maxDischargeW'] = 3000;
case EmsRole.solarMeter:
_params['powerW'] = 6000;
case EmsRole.gridMeter:
_params['breakerKVA'] = 12;
}
// Pré-remplir avec les valeurs existantes
if (widget.existing != null) {
_params.addAll(widget.existing!.params);
_selectedThing = widget.existing!.thing;
}
}
@override
void initState() {
super.initState();
_initParams();
if (widget.existing != null) {
_step = 1; // Va directement à l'étape paramètres si édition
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
maxChildSize: 0.95,
minChildSize: 0.5,
expand: false,
builder: (context, sc) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
children: [
// ── Drag handle ───────────────────────────────────────────────
const SizedBox(height: 12),
Center(
child: Container(
width: 36, height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
// ── Titre + stepper ───────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text(
'${widget.role.icon} ${widget.role.label}',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
_StepDots(current: _step, total: 3),
],
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
_stepTitle(),
style: const TextStyle(
fontSize: 13,
color: AppTheme.textLight,
),
),
),
const Divider(height: 20),
// ── Contenu de l'étape ────────────────────────────────────────
Expanded(
child: SingleChildScrollView(
controller: sc,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _stepContent(context),
),
),
// ── Boutons de navigation ─────────────────────────────────────
_StepButtons(
step: _step,
canNext: _canProceed(),
onBack: _step > 0 ? () => setState(() => _step--) : null,
onNext: _canProceed() ? _next : null,
onCancel: () => Navigator.pop(context),
),
],
),
);
},
);
}
String _stepTitle() => switch (_step) {
0 => 'Choisissez un appareil compatible',
1 => 'Paramètres de configuration',
_ => 'Test de connexion',
};
bool _canProceed() => switch (_step) {
0 => _selectedThing != null,
1 => true,
_ => true,
};
void _next() {
if (_step < 2) {
setState(() => _step++);
if (_step == 2) _runTest();
} else {
_save();
}
}
Future<void> _runTest() async {
final setup = context.read<EnergySetupProvider>();
final service = context.read<NymeaService>();
// Assigne temporairement pour le test
setup.assign(widget.role, _selectedThing!, _params);
await setup.testConnection(widget.role, service);
if (mounted) setState(() {});
}
void _save() {
context
.read<EnergySetupProvider>()
.assign(widget.role, _selectedThing!, _params);
Navigator.pop(context);
}
// ── Contenu des étapes ────────────────────────────────────────────────────
Widget _stepContent(BuildContext context) {
return switch (_step) {
0 => _Step1ThingList(
role: widget.role,
selected: _selectedThing,
searchQuery: _searchQuery,
onSearch: (q) => setState(() => _searchQuery = q),
onSelect: (t) => setState(() => _selectedThing = t),
),
1 => _Step2Params(
role: widget.role,
params: _params,
things: context.read<NymeaService>().things,
onChange: (k, v) => setState(() => _params[k] = v),
),
_ => _Step3Test(
role: widget.role,
onRetry: _runTest,
onSkip: _save,
),
};
}
}
// ── Étape 1 — Choix du thing ──────────────────────────────────────────────────
class _Step1ThingList extends StatelessWidget {
final EmsRole role;
final NymeaThing? selected;
final String searchQuery;
final ValueChanged<String> onSearch;
final ValueChanged<NymeaThing> onSelect;
const _Step1ThingList({
required this.role,
required this.selected,
required this.searchQuery,
required this.onSearch,
required this.onSelect,
});
/// Retourne le label d'interface le plus pertinent d'un thing pour ce rôle.
String _ifaceLabel(NymeaThing thing, NymeaService service) {
try {
final cls = service.thingClasses
.firstWhere((c) => c.id == thing.thingClassId);
for (final iface in role.compatibleInterfaces) {
if (cls.interfaces.any((i) => i.toLowerCase() == iface)) {
return role.interfaceLabelFor(iface);
}
}
return cls.interfaces.firstOrNull ?? '';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final setup = context.read<EnergySetupProvider>();
final service = context.read<NymeaService>();
final things = setup.compatibleThings(role, service)
.where((t) => searchQuery.isEmpty ||
t.name.toLowerCase().contains(searchQuery.toLowerCase()))
.toList();
return Column(
children: [
// Barre de recherche
TextField(
decoration: InputDecoration(
hintText: 'Rechercher un appareil...',
prefixIcon: const Icon(Icons.search_rounded, size: 20),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
onChanged: onSearch,
),
const SizedBox(height: 16),
if (things.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Icon(Icons.device_unknown_rounded,
size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
searchQuery.isEmpty
? 'Aucun appareil compatible pour ce rôle'
: 'Aucun résultat pour "$searchQuery"',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textLight),
),
],
),
)
else
...things.map((t) {
final isSelected = selected?.id == t.id;
return _ThingTile(
thing: t,
ifaceLabel: _ifaceLabel(t, service),
isSelected: isSelected,
onTap: () => onSelect(t),
);
}),
const SizedBox(height: 16),
],
);
}
}
class _ThingTile extends StatelessWidget {
final NymeaThing thing;
final String ifaceLabel;
final bool isSelected;
final VoidCallback onTap;
const _ThingTile({
required this.thing,
required this.ifaceLabel,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? ETMTheme.accentColor.withValues(alpha: 0.08)
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? ETMTheme.accentColor
: Colors.grey.shade200,
),
),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.devices_rounded,
color: AppTheme.textLight),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(thing.name,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 13)),
Text(
ifaceLabel.isNotEmpty ? ifaceLabel : thing.thingClassId,
style: const TextStyle(
fontSize: 11, color: AppTheme.textLight),
),
],
),
),
if (isSelected)
const Icon(Icons.check_circle_rounded,
color: ETMTheme.accentColor, size: 20),
],
),
),
);
}
}
// ── Étape 2 — Paramètres ──────────────────────────────────────────────────────
class _Step2Params extends StatelessWidget {
final EmsRole role;
final Map<String, dynamic> params;
final List<NymeaThing> things;
final void Function(String, dynamic) onChange;
const _Step2Params({
required this.role,
required this.params,
required this.things,
required this.onChange,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
...switch (role) {
EmsRole.evCharger => _evChargerParams(),
EmsRole.dhw => _relayParams(),
EmsRole.heatPump => _heatPumpParams(things),
EmsRole.battery => _batteryParams(),
EmsRole.solarMeter => _solarMeterParams(),
EmsRole.gridMeter => _gridMeterParams(),
},
const SizedBox(height: 16),
],
);
}
List<Widget> _evChargerParams() => [
_NumField('Courant min (A)', 'minA', params, onChange, min: 6, max: 16),
_NumField('Courant max (A)', 'maxA', params, onChange, min: 6, max: 32),
_PriorityDropdown(params, onChange),
];
List<Widget> _relayParams() => [
_NumField('Puissance nominale (W)', 'powerW', params, onChange,
min: 100, max: 20000),
_PriorityDropdown(params, onChange),
];
List<Widget> _heatPumpParams(List<NymeaThing> things) => [
_NumField('Puissance normale (W)', 'powerW', params, onChange,
min: 500, max: 20000),
_PriorityDropdown(params, onChange),
];
List<Widget> _batteryParams() => [
_NumField('Capacité (Wh)', 'capacityWh', params, onChange,
min: 1000, max: 100000),
_NumField('Puissance max charge (W)', 'maxChargeW', params, onChange,
min: 100, max: 20000),
_NumField('Puissance max décharge (W)', 'maxDischargeW', params, onChange,
min: 100, max: 20000),
];
List<Widget> _solarMeterParams() => [
_NumField('Puissance crête onduleur (W)', 'powerW', params, onChange,
min: 500, max: 100000),
];
List<Widget> _gridMeterParams() => [
_NumField('Puissance de coupure (kVA)', 'breakerKVA', params, onChange,
min: 3, max: 630),
];
}
class _NumField extends StatelessWidget {
final String label;
final String paramKey;
final Map<String, dynamic> params;
final void Function(String, dynamic) onChange;
final double min;
final double max;
const _NumField(this.label, this.paramKey, this.params, this.onChange,
{required this.min, required this.max});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextFormField(
initialValue: params[paramKey]?.toString() ?? '',
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (v) {
final n = num.tryParse(v);
if (n != null) onChange(paramKey, n);
},
),
);
}
}
class _PriorityDropdown extends StatelessWidget {
final Map<String, dynamic> params;
final void Function(String, dynamic) onChange;
const _PriorityDropdown(this.params, this.onChange);
@override
Widget build(BuildContext context) {
final priorities = ['Critical', 'High', 'Normal', 'Low'];
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: DropdownButtonFormField<String>(
value: params['priority'] as String? ?? 'Normal',
decoration: InputDecoration(
labelText: 'Priorité',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
items: priorities
.map((p) => DropdownMenuItem(value: p, child: Text(p)))
.toList(),
onChanged: (v) { if (v != null) onChange('priority', v); },
),
);
}
}
// ── Étape 3 — Test de connexion ───────────────────────────────────────────────
class _Step3Test extends StatelessWidget {
final EmsRole role;
final VoidCallback onRetry;
final VoidCallback onSkip;
const _Step3Test({
required this.role,
required this.onRetry,
required this.onSkip,
});
@override
Widget build(BuildContext context) {
final result = context.watch<EnergySetupProvider>().testResult;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: switch (result.status) {
ConnectionTestStatus.idle || ConnectionTestStatus.testing => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 20),
const Text(
'Test de connexion en cours...',
style: TextStyle(fontSize: 15),
),
],
),
ConnectionTestStatus.success => Column(
children: [
Container(
width: 60, height: 60,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded,
color: AppTheme.primaryGreen, size: 32),
),
const SizedBox(height: 16),
const Text('Connexion réussie',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(
result.message ?? '',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textLight),
),
],
),
ConnectionTestStatus.failure => Column(
children: [
Container(
width: 60, height: 60,
decoration: BoxDecoration(
color: AppTheme.boostRed.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.close_rounded,
color: AppTheme.boostRed, size: 32),
),
const SizedBox(height: 16),
const Text('Échec de la connexion',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(
result.message ??
"L'appareil n'a pas répondu dans les 5 secondes",
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textLight),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: ETMTheme.accentColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
onPressed: onRetry,
),
const SizedBox(height: 10),
TextButton(
onPressed: onSkip,
child: const Text('Configurer quand même'),
),
],
),
},
);
}
}
// ── Composants partagés ───────────────────────────────────────────────────────
class _StepDots extends StatelessWidget {
final int current;
final int total;
const _StepDots({required this.current, required this.total});
@override
Widget build(BuildContext context) {
return Row(
children: List.generate(total, (i) {
final active = i == current;
return Container(
width: active ? 16 : 8,
height: 8,
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: active ? ETMTheme.accentColor : Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
);
}),
);
}
}
class _StepButtons extends StatelessWidget {
final int step;
final bool canNext;
final VoidCallback? onBack;
final VoidCallback? onNext;
final VoidCallback onCancel;
const _StepButtons({
required this.step,
required this.canNext,
required this.onBack,
required this.onNext,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final result = context.watch<EnergySetupProvider>().testResult;
final isTesting =
result.status == ConnectionTestStatus.testing;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Row(
children: [
if (onBack != null)
TextButton(
onPressed: onBack,
child: const Text('← Retour'),
)
else
TextButton(
onPressed: onCancel,
child: const Text('Annuler'),
),
const Spacer(),
if (step < 2 || result.status == ConnectionTestStatus.success)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ETMTheme.accentColor,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade200,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
),
onPressed: isTesting ? null : onNext,
child: Text(step == 2 ? 'Terminer' : 'Suivant →'),
),
],
),
);
}
}