- 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>
266 lines
8.4 KiB
Dart
266 lines
8.4 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../providers/installer_mode_provider.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../theme/etm_theme.dart';
|
|
|
|
/// Dialog de saisie du PIN installateur.
|
|
/// Affiche un clavier numérique avec indicateurs de saisie,
|
|
/// gestion des tentatives échouées et countdown en cas de verrouillage.
|
|
class InstallerPinDialog extends StatefulWidget {
|
|
const InstallerPinDialog({super.key});
|
|
|
|
@override
|
|
State<InstallerPinDialog> createState() => _InstallerPinDialogState();
|
|
}
|
|
|
|
class _InstallerPinDialogState extends State<InstallerPinDialog> {
|
|
String _pin = '';
|
|
String? _error;
|
|
bool _checking = false;
|
|
Timer? _countdownTimer;
|
|
int _countdown = 0;
|
|
|
|
@override
|
|
void dispose() {
|
|
_countdownTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _append(String digit) {
|
|
if (_checking || _pin.length >= 6) return;
|
|
setState(() {
|
|
_pin = _pin + digit;
|
|
_error = null;
|
|
});
|
|
if (_pin.length >= 4) {
|
|
_tryUnlock();
|
|
}
|
|
}
|
|
|
|
void _delete() {
|
|
if (_pin.isEmpty) return;
|
|
setState(() => _pin = _pin.substring(0, _pin.length - 1));
|
|
}
|
|
|
|
Future<void> _tryUnlock() async {
|
|
final provider = context.read<InstallerModeProvider>();
|
|
if (provider.isLocked) {
|
|
_startCountdown(provider.lockRemaining.inSeconds);
|
|
return;
|
|
}
|
|
|
|
setState(() => _checking = true);
|
|
final result = await provider.unlock(_pin);
|
|
if (!mounted) return;
|
|
|
|
switch (result) {
|
|
case UnlockResult.success:
|
|
Navigator.of(context).pop(true);
|
|
break;
|
|
case UnlockResult.wrongPin:
|
|
setState(() {
|
|
_checking = false;
|
|
_pin = '';
|
|
_error = provider.isLocked
|
|
? 'PIN incorrect. Veuillez patienter.'
|
|
: 'PIN incorrect (${provider.failedAttempts} tentative${provider.failedAttempts > 1 ? 's' : ''} restante${(3 - provider.failedAttempts) > 1 ? 's' : ''})';
|
|
});
|
|
if (provider.isLocked) {
|
|
_startCountdown(provider.lockRemaining.inSeconds);
|
|
}
|
|
break;
|
|
case UnlockResult.locked:
|
|
setState(() {
|
|
_checking = false;
|
|
_pin = '';
|
|
});
|
|
_startCountdown(provider.lockRemaining.inSeconds);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _startCountdown(int seconds) {
|
|
_countdown = seconds;
|
|
_countdownTimer?.cancel();
|
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
|
if (!mounted) { t.cancel(); return; }
|
|
setState(() {
|
|
_countdown--;
|
|
if (_countdown <= 0) {
|
|
t.cancel();
|
|
_error = null;
|
|
} else {
|
|
_error = 'Trop de tentatives. Réessayez dans $_countdown s';
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ── Icône ────────────────────────────────────────────────────────
|
|
Container(
|
|
width: 52, height: 52,
|
|
decoration: BoxDecoration(
|
|
color: ETMTheme.installerBadgeColor.withValues(alpha: 0.12),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.build_rounded,
|
|
color: ETMTheme.installerBadgeColor,
|
|
size: 26,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
const Text(
|
|
'Mode Installateur',
|
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'Entrez votre PIN pour continuer',
|
|
style: TextStyle(fontSize: 13, color: Color(0xFF6B7280)),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// ── Indicateurs PIN ──────────────────────────────────────────────
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(6, (i) {
|
|
final filled = i < _pin.length;
|
|
return Container(
|
|
width: 14, height: 14,
|
|
margin: const EdgeInsets.symmetric(horizontal: 5),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: filled
|
|
? ETMTheme.installerBadgeColor
|
|
: Colors.grey.shade200,
|
|
border: Border.all(
|
|
color: filled
|
|
? ETMTheme.installerBadgeColor
|
|
: Colors.grey.shade400,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
|
|
if (_checking) ...[
|
|
const SizedBox(height: 16),
|
|
const SizedBox(
|
|
width: 24, height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
color: ETMTheme.installerBadgeColor,
|
|
),
|
|
),
|
|
] else if (_error != null) ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
_error!,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: ETMTheme.errorColor,
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// ── Clavier numérique ────────────────────────────────────────────
|
|
...[
|
|
['1', '2', '3'],
|
|
['4', '5', '6'],
|
|
['7', '8', '9'],
|
|
['', '0', '⌫'],
|
|
].map((row) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: row.map((k) {
|
|
if (k.isEmpty) {
|
|
return const SizedBox(width: 70, height: 52);
|
|
}
|
|
return _KeyButton(
|
|
label: k,
|
|
onTap: () {
|
|
if (k == '⌫') {
|
|
_delete();
|
|
} else {
|
|
_append(k);
|
|
}
|
|
},
|
|
isDelete: k == '⌫',
|
|
disabled: _checking || _countdown > 0,
|
|
);
|
|
}).toList(),
|
|
),
|
|
)),
|
|
|
|
// ── Bouton Annuler ───────────────────────────────────────────────
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Annuler'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KeyButton extends StatelessWidget {
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool isDelete;
|
|
final bool disabled;
|
|
|
|
const _KeyButton({
|
|
required this.label,
|
|
required this.onTap,
|
|
this.isDelete = false,
|
|
this.disabled = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: disabled ? null : onTap,
|
|
child: Container(
|
|
width: 70, height: 52,
|
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
|
decoration: BoxDecoration(
|
|
color: isDelete
|
|
? Colors.grey.shade100
|
|
: Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.grey.shade200),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: isDelete ? 18 : 22,
|
|
fontWeight: FontWeight.w500,
|
|
color: disabled
|
|
? Colors.grey.shade400
|
|
: AppTheme.textDark,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|