- 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>
689 lines
22 KiB
Dart
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 →'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|