From fd6db5a29bf6a3d6b56c7de09fdd2174edd34a7e Mon Sep 17 00:00:00 2001 From: dwindown Date: Mon, 30 Mar 2026 22:12:50 +0700 Subject: [PATCH] Add Android TV admin unlock and focus-driven controls --- lib/features/admin/admin_screen.dart | 534 +++++++++++++++++++++++---- lib/features/home/home_view.dart | 165 +++++++-- 2 files changed, 591 insertions(+), 108 deletions(-) diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index 21802e2..71315eb 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -56,6 +56,15 @@ class _AdminScreenState extends ConsumerState { final _iqomahAsharCtrl = TextEditingController(); final _iqomahMaghribCtrl = TextEditingController(); final _iqomahIsyaCtrl = TextEditingController(); + final _preAdzanLeadCtrl = TextEditingController(); + final _blankNormalCtrl = TextEditingController(); + final _blankJumatCtrl = TextEditingController(); + + final _identityScrollController = ScrollController(); + final _jadwalScrollController = ScrollController(); + final _tampilanScrollController = ScrollController(); + final _jumatScrollController = ScrollController(); + final _simulasiScrollController = ScrollController(); int _hijriOffsetDays = 0; @override @@ -95,6 +104,9 @@ class _AdminScreenState extends ConsumerState { _iqomahAsharCtrl.text = settings.iqomahAshar.toString(); _iqomahMaghribCtrl.text = settings.iqomahMaghrib.toString(); _iqomahIsyaCtrl.text = settings.iqomahIsya.toString(); + _preAdzanLeadCtrl.text = settings.preAdzanLead.toString(); + _blankNormalCtrl.text = settings.blankScreenNormal.toString(); + _blankJumatCtrl.text = settings.blankScreenJumat.toString(); _hijriOffsetDays = settings.hijriOffsetDays; // Update preview live as admin types @@ -118,6 +130,14 @@ class _AdminScreenState extends ConsumerState { _iqomahAsharCtrl.dispose(); _iqomahMaghribCtrl.dispose(); _iqomahIsyaCtrl.dispose(); + _preAdzanLeadCtrl.dispose(); + _blankNormalCtrl.dispose(); + _blankJumatCtrl.dispose(); + _identityScrollController.dispose(); + _jadwalScrollController.dispose(); + _tampilanScrollController.dispose(); + _jumatScrollController.dispose(); + _simulasiScrollController.dispose(); super.dispose(); } @@ -167,8 +187,11 @@ class _AdminScreenState extends ConsumerState { } } - Future _saveIqomahSettings() async { + Future _saveJadwalTimingSettings() async { await ref.read(settingsProvider.notifier).updateSettings((s) { + s.preAdzanLead = int.tryParse(_preAdzanLeadCtrl.text.trim()) ?? 10; + s.blankScreenNormal = int.tryParse(_blankNormalCtrl.text.trim()) ?? 15; + s.blankScreenJumat = int.tryParse(_blankJumatCtrl.text.trim()) ?? 45; s.iqomahSubuh = int.tryParse(_iqomahSubuhCtrl.text.trim()) ?? 15; s.iqomahDzuhur = int.tryParse(_iqomahDzuhurCtrl.text.trim()) ?? 10; s.iqomahAshar = int.tryParse(_iqomahAsharCtrl.text.trim()) ?? 10; @@ -176,10 +199,14 @@ class _AdminScreenState extends ConsumerState { s.iqomahIsya = int.tryParse(_iqomahIsyaCtrl.text.trim()) ?? 10; return s; }); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Jeda Iqamah berhasil disimpan', style: GoogleFonts.manrope()), + content: Text( + 'Pengaturan jadwal dan durasi berhasil disimpan', + style: GoogleFonts.manrope(), + ), backgroundColor: SacredColors.primaryContainer, ), ); @@ -353,9 +380,11 @@ class _AdminScreenState extends ConsumerState { ), iconTheme: const IconThemeData(color: SacredColors.primary), ), - body: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + body: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ // Nav rail area Container( width: 350 * s, @@ -423,6 +452,7 @@ class _AdminScreenState extends ConsumerState { ), ), ], + ), ), ); } @@ -445,6 +475,7 @@ class _AdminScreenState extends ConsumerState { Widget _buildJumatTab(double s) { return SingleChildScrollView( + controller: _jumatScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -567,6 +598,7 @@ class _AdminScreenState extends ConsumerState { Widget _buildTampilanTab(double s) { return SingleChildScrollView( + controller: _tampilanScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -591,26 +623,39 @@ class _AdminScreenState extends ConsumerState { children: [ _sectionLabel('Tipografi & Skala Teks', s), SizedBox(height: 12 * s), - DropdownButtonFormField( - initialValue: _textScaleIndex, - onChanged: (val) => setState(() => _textScaleIndex = val ?? 1), - items: const [ - DropdownMenuItem(value: 0, child: Text('Kecil (Small)')), - DropdownMenuItem(value: 1, child: Text('Normal (Medium)')), - DropdownMenuItem(value: 2, child: Text('Besar (Large)')), + SegmentedButton( + segments: const [ + ButtonSegment(value: 0, label: Text('Kecil')), + ButtonSegment(value: 1, label: Text('Normal')), + ButtonSegment(value: 2, label: Text('Besar')), ], - style: GoogleFonts.plusJakartaSans(fontSize: 22 * s, color: SacredColors.onSurface), - dropdownColor: SacredColors.surfaceContainerHighest, - decoration: InputDecoration( - filled: true, - fillColor: SacredColors.surfaceContainerLowest, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.md)), - ), + selected: {_textScaleIndex}, + onSelectionChanged: (val) { + setState(() => _textScaleIndex = val.first); + }, ), SizedBox(height: 28 * s), - _buildTextField('Durasi Layar Utama (Detik)', _mainDurCtrl, s), + _buildTvIntStepperField( + s: s, + label: 'Durasi Layar Utama', + controller: _mainDurCtrl, + fallback: 15, + min: 5, + max: 120, + suffix: 'detik', + fastStep: 10, + ), SizedBox(height: 24 * s), - _buildTextField('Durasi Tiap Slideshow (Detik)', _slideDurCtrl, s), + _buildTvIntStepperField( + s: s, + label: 'Durasi Tiap Slideshow', + controller: _slideDurCtrl, + fallback: 10, + min: 5, + max: 120, + suffix: 'detik', + fastStep: 10, + ), SizedBox(height: 40 * s), _sectionLabel('Ukuran Teks Per Kelompok', s), @@ -656,7 +701,15 @@ class _AdminScreenState extends ConsumerState { SizedBox(height: 12 * s), _buildTextField('Kata Kunci (Contoh: mosque, architecture)', _unsplashKeywordCtrl, s), SizedBox(height: 12 * s), - _buildTextField('Rotasi Foto (Jam)', _unsplashRotationCtrl, s), + _buildTvIntStepperField( + s: s, + label: 'Rotasi Foto', + controller: _unsplashRotationCtrl, + fallback: 6, + min: 1, + max: 24, + suffix: 'jam', + ), ], SizedBox(height: 56 * s), @@ -968,15 +1021,18 @@ class _AdminScreenState extends ConsumerState { } Widget _adminCard(double s, {required Widget child}) { - return Container( - width: double.infinity, - padding: EdgeInsets.all(36 * s), - decoration: BoxDecoration( - color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(SacredRadii.xl), - border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)), + return _scrollAware( + controller: _scrollControllerForTab(_selectedTab), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(36 * s), + decoration: BoxDecoration( + color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(SacredRadii.xl), + border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)), + ), + child: child, ), - child: child, ); } @@ -994,7 +1050,9 @@ class _AdminScreenState extends ConsumerState { Widget _buildIdentityTab(double s) { - return Column( + return SingleChildScrollView( + controller: _identityScrollController, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -1077,6 +1135,7 @@ class _AdminScreenState extends ConsumerState { ), ), ], + ), ); } @@ -1086,6 +1145,7 @@ class _AdminScreenState extends ConsumerState { final displayedHijri = ref.watch(hijriDateProvider).valueOrNull; return SingleChildScrollView( + controller: _jadwalScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1259,39 +1319,143 @@ class _AdminScreenState extends ConsumerState { SizedBox(height: 64 * s), - // Jeda Waktu Iqamah Settings Card + // Waktu & Durasi Card _adminCard(s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionLabel('Jeda Waktu Iqamah (Menit)', s), + _sectionLabel('Waktu & Durasi', s), SizedBox(height: 8 * s), Text( - 'Tentukan durasi hitung mundur dari Adzan selesai (1 menit setelah masuk waktu) hingga iqamah. Selama jeda ini, jamaah dapat melakukan shalat sunnah.', + 'Seluruh pengaturan angka utama untuk alur jadwal ditangani dengan stepper agar nyaman dipakai dengan remote Android TV.', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 32 * s), Row( children: [ - Expanded(child: _buildTextField('Iqamah Subuh', _iqomahSubuhCtrl, s)), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Pra-Adzan', + controller: _preAdzanLeadCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + ), + ), SizedBox(width: 16 * s), - Expanded(child: _buildTextField('Iqamah Dzuhur', _iqomahDzuhurCtrl, s)), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Blank Screen Normal', + controller: _blankNormalCtrl, + fallback: 15, + min: 0, + max: 120, + suffix: 'menit', + ), + ), SizedBox(width: 16 * s), - Expanded(child: _buildTextField('Iqamah Ashar', _iqomahAsharCtrl, s)), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Blank Screen Jumat', + controller: _blankJumatCtrl, + fallback: 45, + min: 0, + max: 180, + suffix: 'menit', + ), + ), + ], + ), + SizedBox(height: 28 * s), + Text( + 'Jeda Waktu Iqamah (Menit)', + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w700, + color: SacredColors.onSurface, + ), + ), + SizedBox(height: 8 * s), + Text( + 'Tentukan durasi hitung mundur dari selesai Adzan hingga iqamah untuk tiap shalat fardhu.', + style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), + ), + SizedBox(height: 24 * s), + Row( + children: [ + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Iqamah Subuh', + controller: _iqomahSubuhCtrl, + fallback: 15, + min: 0, + max: 60, + suffix: 'menit', + ), + ), + SizedBox(width: 16 * s), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Iqamah Dzuhur', + controller: _iqomahDzuhurCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + ), + ), + SizedBox(width: 16 * s), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Iqamah Ashar', + controller: _iqomahAsharCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + ), + ), ], ), SizedBox(height: 16 * s), Row( children: [ - Expanded(child: _buildTextField('Iqamah Maghrib', _iqomahMaghribCtrl, s)), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Iqamah Maghrib', + controller: _iqomahMaghribCtrl, + fallback: 7, + min: 0, + max: 60, + suffix: 'menit', + ), + ), SizedBox(width: 16 * s), - Expanded(child: _buildTextField('Iqamah Isya', _iqomahIsyaCtrl, s)), + Expanded( + child: _buildTvIntStepperField( + s: s, + label: 'Iqamah Isya', + controller: _iqomahIsyaCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + ), + ), SizedBox(width: 16 * s), Expanded(child: SizedBox()), // spacer ], ), SizedBox(height: 32 * s), ElevatedButton.icon( - onPressed: _saveIqomahSettings, + onPressed: _saveJadwalTimingSettings, style: ElevatedButton.styleFrom( backgroundColor: SacredColors.secondary, foregroundColor: Colors.black, @@ -1300,7 +1464,7 @@ class _AdminScreenState extends ConsumerState { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), ), icon: const Icon(Icons.timer), - label: const Text('SIMPAN JEDA IQAMAH'), + label: const Text('SIMPAN PENGATURAN JADWAL'), ), ], )), @@ -1575,8 +1739,207 @@ class _AdminScreenState extends ConsumerState { ); } + ScrollController _scrollControllerForTab(int tabIndex) { + switch (tabIndex) { + case 0: + return _identityScrollController; + case 1: + return _jadwalScrollController; + case 2: + return _tampilanScrollController; + case 3: + return _jumatScrollController; + case 4: + default: + return _simulasiScrollController; + } + } + + Widget _scrollAware({ + required ScrollController controller, + required Widget child, + }) { + return Builder( + builder: (context) { + return Focus( + onFocusChange: (hasFocus) { + if (!hasFocus || !controller.hasClients) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: 0.18, + ); + } + }); + }, + child: child, + ); + }, + ); + } + + int _parseCtrlInt(TextEditingController ctrl, int fallback) { + return int.tryParse(ctrl.text.trim()) ?? fallback; + } + + void _bumpCtrlInt( + TextEditingController ctrl, { + required int delta, + required int min, + required int max, + required int fallback, + }) { + final next = (_parseCtrlInt(ctrl, fallback) + delta).clamp(min, max); + setState(() { + ctrl.text = next.toString(); + }); + } + + Widget _buildTvIntStepperField({ + required double s, + required String label, + required TextEditingController controller, + required int fallback, + required int min, + required int max, + String suffix = '', + int fastStep = 5, + }) { + final value = _parseCtrlInt(controller, fallback); + final valueLabel = suffix.isEmpty ? '$value' : '$value $suffix'; + + return _scrollAware( + controller: _scrollControllerForTab(_selectedTab), + child: Container( + padding: EdgeInsets.all(16 * s), + decoration: BoxDecoration( + color: SacredColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(SacredRadii.md), + border: Border.all( + color: SacredColors.outlineVariant.withValues(alpha: 0.25), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + label, + style: GoogleFonts.manrope( + fontSize: 15 * s, + fontWeight: FontWeight.w500, + color: SacredColors.onSurface, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 14 * s, + vertical: 5 * s, + ), + decoration: BoxDecoration( + color: SacredColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(SacredRadii.sm), + ), + child: Text( + valueLabel, + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w800, + color: SacredColors.primary, + ), + ), + ), + ], + ), + SizedBox(height: 14 * s), + Row( + children: [ + _tvStepBtn( + s: s, + label: '−−', + onPressed: () => _bumpCtrlInt( + controller, + delta: -fastStep, + min: min, + max: max, + fallback: fallback, + ), + ), + SizedBox(width: 6 * s), + _tvStepBtn( + s: s, + label: '−', + onPressed: () => _bumpCtrlInt( + controller, + delta: -1, + min: min, + max: max, + fallback: fallback, + ), + ), + SizedBox(width: 10 * s), + Expanded( + child: Container( + height: 6 * s, + decoration: BoxDecoration( + color: SacredColors.outlineVariant.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(3 * s), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: ((value - min) / (max - min)).clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: SacredColors.primary, + borderRadius: BorderRadius.circular(3 * s), + ), + ), + ), + ), + ), + SizedBox(width: 10 * s), + _tvStepBtn( + s: s, + label: '+', + onPressed: () => _bumpCtrlInt( + controller, + delta: 1, + min: min, + max: max, + fallback: fallback, + ), + ), + SizedBox(width: 6 * s), + _tvStepBtn( + s: s, + label: '++', + onPressed: () => _bumpCtrlInt( + controller, + delta: fastStep, + min: min, + max: max, + fallback: fallback, + ), + ), + ], + ), + ], + ), + ), + ); + } + Widget _buildTextField(String label, TextEditingController ctrl, double s, {int maxLines = 1}) { - return Column( + return _scrollAware( + controller: _scrollControllerForTab(_selectedTab), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -1609,6 +1972,7 @@ class _AdminScreenState extends ConsumerState { ), ), ], + ), ); } @@ -1783,6 +2147,7 @@ class _AdminScreenState extends ConsumerState { Widget _buildSimulasiTab(double s) { return SingleChildScrollView( + controller: _simulasiScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1946,7 +2311,7 @@ class _AdminScreenState extends ConsumerState { } } -class _NavButton extends StatelessWidget { +class _NavButton extends StatefulWidget { final String title; final dynamic icon; final bool isActive; @@ -1961,40 +2326,63 @@ class _NavButton extends StatelessWidget { required this.onTap, }); + @override + State<_NavButton> createState() => _NavButtonState(); +} + +class _NavButtonState extends State<_NavButton> { + bool _isFocused = false; + @override Widget build(BuildContext context) { - final s = scale; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(SacredRadii.lg), - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s), - decoration: BoxDecoration( - color: isActive ? SacredColors.primaryContainer : Colors.transparent, - borderRadius: BorderRadius.circular(SacredRadii.lg), - border: isActive ? Border.all(color: SacredColors.primary.withValues(alpha: 0.3)) : null, - ), - child: Row( - children: [ - HugeIcon( - icon: icon, - color: isActive ? SacredColors.onPrimaryContainer : SacredColors.onSurfaceVariant, - size: 28 * s, - ), - SizedBox(width: 20 * s), - Expanded( - child: Text( - title, - style: GoogleFonts.plusJakartaSans( - fontSize: 18 * s, - fontWeight: FontWeight.bold, - color: isActive ? SacredColors.onPrimaryContainer : SacredColors.onSurfaceVariant, - letterSpacing: 1 * s, + final s = widget.scale; + final highlight = widget.isActive || _isFocused; + + return FocusableActionDetector( + onShowFocusHighlight: (value) => setState(() => _isFocused = value), + child: InkWell( + onTap: widget.onTap, + focusColor: SacredColors.primary.withValues(alpha: 0.22), + hoverColor: SacredColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(SacredRadii.lg), + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s), + decoration: BoxDecoration( + color: highlight ? SacredColors.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: highlight + ? Border.all( + color: SacredColors.primary.withValues(alpha: 0.4), + width: _isFocused ? 2 : 1, + ) + : null, + ), + child: Row( + children: [ + HugeIcon( + icon: widget.icon, + color: highlight + ? SacredColors.onPrimaryContainer + : SacredColors.onSurfaceVariant, + size: 28 * s, + ), + SizedBox(width: 20 * s), + Expanded( + child: Text( + widget.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 18 * s, + fontWeight: FontWeight.bold, + color: highlight + ? SacredColors.onPrimaryContainer + : SacredColors.onSurfaceVariant, + letterSpacing: 1 * s, + ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/features/home/home_view.dart b/lib/features/home/home_view.dart index 97c74a1..68f2b20 100644 --- a/lib/features/home/home_view.dart +++ b/lib/features/home/home_view.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/services/sync_service.dart'; @@ -6,6 +9,7 @@ import '../../data/services/sound_service.dart'; import '../../core/enums.dart'; import '../../core/sacred_tokens.dart'; import '../../providers.dart'; +import '../admin/admin_screen.dart'; import 'main_screen.dart'; import 'adzan_screen.dart'; import 'iqomah_screen.dart'; @@ -23,14 +27,40 @@ class HomeView extends ConsumerStatefulWidget { } class _HomeViewState extends ConsumerState { + static const List _adminUnlockSequence = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.select, + ]; + + final FocusNode _homeFocusNode = FocusNode(debugLabel: 'home_tv_root'); + final List _recentKeys = []; + Timer? _comboResetTimer; + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _checkAutoSync(); + if (mounted) { + _homeFocusNode.requestFocus(); + } }); } + @override + void dispose() { + _comboResetTimer?.cancel(); + _homeFocusNode.dispose(); + super.dispose(); + } + Future _checkAutoSync() async { final schedule = ref.read(todayScheduleProvider); if (schedule == null) { @@ -45,6 +75,66 @@ class _HomeViewState extends ConsumerState { } } + KeyEventResult _handleTvKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey; + if (!_isComboKey(key)) { + _resetCombo(); + return KeyEventResult.ignored; + } + + _comboResetTimer?.cancel(); + _comboResetTimer = Timer(const Duration(seconds: 3), _resetCombo); + + _recentKeys.add(key); + if (_recentKeys.length > _adminUnlockSequence.length) { + _recentKeys.removeAt(0); + } + + if (_matchesUnlockSequence()) { + _resetCombo(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AdminScreen()), + ); + if (mounted) { + _homeFocusNode.requestFocus(); + } + }); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + bool _isComboKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter; + } + + bool _matchesUnlockSequence() { + if (_recentKeys.length != _adminUnlockSequence.length) return false; + + for (var i = 0; i < _adminUnlockSequence.length; i++) { + final current = _recentKeys[i] == LogicalKeyboardKey.enter + ? LogicalKeyboardKey.select + : _recentKeys[i]; + if (current != _adminUnlockSequence[i]) return false; + } + return true; + } + + void _resetCombo() { + _comboResetTimer?.cancel(); + _recentKeys.clear(); + } + @override Widget build(BuildContext context) { // Audio trigger listener @@ -101,44 +191,49 @@ class _HomeViewState extends ConsumerState { final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero; - return Scaffold( - backgroundColor: SacredColors.background, - body: Stack( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 800), - transitionBuilder: (child, animation) { - return FadeTransition(opacity: animation, child: child); - }, - child: screen, - ), - - if (isSimulating) - Positioned( - right: 64, - bottom: 64, - child: ElevatedButton.icon( - onPressed: () { - ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero; - }, - icon: const Icon(Icons.cancel, color: Colors.white), - label: const Text( - 'BATALKAN SIMULASI', - style: TextStyle( - fontWeight: FontWeight.bold, - letterSpacing: 2, - color: Colors.white, + return Focus( + autofocus: true, + focusNode: _homeFocusNode, + onKeyEvent: _handleTvKey, + child: Scaffold( + backgroundColor: SacredColors.background, + body: Stack( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 800), + transitionBuilder: (child, animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: screen, + ), + + if (isSimulating) + Positioned( + right: 64, + bottom: 64, + child: ElevatedButton.icon( + onPressed: () { + ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero; + }, + icon: const Icon(Icons.cancel, color: Colors.white), + label: const Text( + 'BATALKAN SIMULASI', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 2, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade800, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 10, ), ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade800, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - elevation: 10, - ), ), - ), - ], + ], + ), ), ); }