Add Android TV admin unlock and focus-driven controls

This commit is contained in:
dwindown
2026-03-30 22:12:50 +07:00
parent 9b126646a9
commit fd6db5a29b
2 changed files with 591 additions and 108 deletions

View File

@@ -56,6 +56,15 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
final _iqomahAsharCtrl = TextEditingController(); final _iqomahAsharCtrl = TextEditingController();
final _iqomahMaghribCtrl = TextEditingController(); final _iqomahMaghribCtrl = TextEditingController();
final _iqomahIsyaCtrl = 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; int _hijriOffsetDays = 0;
@override @override
@@ -95,6 +104,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_iqomahAsharCtrl.text = settings.iqomahAshar.toString(); _iqomahAsharCtrl.text = settings.iqomahAshar.toString();
_iqomahMaghribCtrl.text = settings.iqomahMaghrib.toString(); _iqomahMaghribCtrl.text = settings.iqomahMaghrib.toString();
_iqomahIsyaCtrl.text = settings.iqomahIsya.toString(); _iqomahIsyaCtrl.text = settings.iqomahIsya.toString();
_preAdzanLeadCtrl.text = settings.preAdzanLead.toString();
_blankNormalCtrl.text = settings.blankScreenNormal.toString();
_blankJumatCtrl.text = settings.blankScreenJumat.toString();
_hijriOffsetDays = settings.hijriOffsetDays; _hijriOffsetDays = settings.hijriOffsetDays;
// Update preview live as admin types // Update preview live as admin types
@@ -118,6 +130,14 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_iqomahAsharCtrl.dispose(); _iqomahAsharCtrl.dispose();
_iqomahMaghribCtrl.dispose(); _iqomahMaghribCtrl.dispose();
_iqomahIsyaCtrl.dispose(); _iqomahIsyaCtrl.dispose();
_preAdzanLeadCtrl.dispose();
_blankNormalCtrl.dispose();
_blankJumatCtrl.dispose();
_identityScrollController.dispose();
_jadwalScrollController.dispose();
_tampilanScrollController.dispose();
_jumatScrollController.dispose();
_simulasiScrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -167,8 +187,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
} }
} }
Future<void> _saveIqomahSettings() async { Future<void> _saveJadwalTimingSettings() async {
await ref.read(settingsProvider.notifier).updateSettings((s) { 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.iqomahSubuh = int.tryParse(_iqomahSubuhCtrl.text.trim()) ?? 15;
s.iqomahDzuhur = int.tryParse(_iqomahDzuhurCtrl.text.trim()) ?? 10; s.iqomahDzuhur = int.tryParse(_iqomahDzuhurCtrl.text.trim()) ?? 10;
s.iqomahAshar = int.tryParse(_iqomahAsharCtrl.text.trim()) ?? 10; s.iqomahAshar = int.tryParse(_iqomahAsharCtrl.text.trim()) ?? 10;
@@ -176,10 +199,14 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
s.iqomahIsya = int.tryParse(_iqomahIsyaCtrl.text.trim()) ?? 10; s.iqomahIsya = int.tryParse(_iqomahIsyaCtrl.text.trim()) ?? 10;
return s; return s;
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Jeda Iqamah berhasil disimpan', style: GoogleFonts.manrope()), content: Text(
'Pengaturan jadwal dan durasi berhasil disimpan',
style: GoogleFonts.manrope(),
),
backgroundColor: SacredColors.primaryContainer, backgroundColor: SacredColors.primaryContainer,
), ),
); );
@@ -353,9 +380,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
), ),
iconTheme: const IconThemeData(color: SacredColors.primary), iconTheme: const IconThemeData(color: SacredColors.primary),
), ),
body: Row( body: FocusTraversalGroup(
crossAxisAlignment: CrossAxisAlignment.start, policy: ReadingOrderTraversalPolicy(),
children: [ child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nav rail area // Nav rail area
Container( Container(
width: 350 * s, width: 350 * s,
@@ -423,6 +452,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
), ),
), ),
], ],
),
), ),
); );
} }
@@ -445,6 +475,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Widget _buildJumatTab(double s) { Widget _buildJumatTab(double s) {
return SingleChildScrollView( return SingleChildScrollView(
controller: _jumatScrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -567,6 +598,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Widget _buildTampilanTab(double s) { Widget _buildTampilanTab(double s) {
return SingleChildScrollView( return SingleChildScrollView(
controller: _tampilanScrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -591,26 +623,39 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
children: [ children: [
_sectionLabel('Tipografi & Skala Teks', s), _sectionLabel('Tipografi & Skala Teks', s),
SizedBox(height: 12 * s), SizedBox(height: 12 * s),
DropdownButtonFormField<int>( SegmentedButton<int>(
initialValue: _textScaleIndex, segments: const [
onChanged: (val) => setState(() => _textScaleIndex = val ?? 1), ButtonSegment(value: 0, label: Text('Kecil')),
items: const [ ButtonSegment(value: 1, label: Text('Normal')),
DropdownMenuItem(value: 0, child: Text('Kecil (Small)')), ButtonSegment(value: 2, label: Text('Besar')),
DropdownMenuItem(value: 1, child: Text('Normal (Medium)')),
DropdownMenuItem(value: 2, child: Text('Besar (Large)')),
], ],
style: GoogleFonts.plusJakartaSans(fontSize: 22 * s, color: SacredColors.onSurface), selected: {_textScaleIndex},
dropdownColor: SacredColors.surfaceContainerHighest, onSelectionChanged: (val) {
decoration: InputDecoration( setState(() => _textScaleIndex = val.first);
filled: true, },
fillColor: SacredColors.surfaceContainerLowest,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.md)),
),
), ),
SizedBox(height: 28 * s), 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), 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), SizedBox(height: 40 * s),
_sectionLabel('Ukuran Teks Per Kelompok', s), _sectionLabel('Ukuran Teks Per Kelompok', s),
@@ -656,7 +701,15 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
SizedBox(height: 12 * s), SizedBox(height: 12 * s),
_buildTextField('Kata Kunci (Contoh: mosque, architecture)', _unsplashKeywordCtrl, s), _buildTextField('Kata Kunci (Contoh: mosque, architecture)', _unsplashKeywordCtrl, s),
SizedBox(height: 12 * 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), SizedBox(height: 56 * s),
@@ -968,15 +1021,18 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
} }
Widget _adminCard(double s, {required Widget child}) { Widget _adminCard(double s, {required Widget child}) {
return Container( return _scrollAware(
width: double.infinity, controller: _scrollControllerForTab(_selectedTab),
padding: EdgeInsets.all(36 * s), child: Container(
decoration: BoxDecoration( width: double.infinity,
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3), padding: EdgeInsets.all(36 * s),
borderRadius: BorderRadius.circular(SacredRadii.xl), decoration: BoxDecoration(
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)), 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<AdminScreen> {
Widget _buildIdentityTab(double s) { Widget _buildIdentityTab(double s) {
return Column( return SingleChildScrollView(
controller: _identityScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -1077,6 +1135,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
), ),
), ),
], ],
),
); );
} }
@@ -1086,6 +1145,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
final displayedHijri = ref.watch(hijriDateProvider).valueOrNull; final displayedHijri = ref.watch(hijriDateProvider).valueOrNull;
return SingleChildScrollView( return SingleChildScrollView(
controller: _jadwalScrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1259,39 +1319,143 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
SizedBox(height: 64 * s), SizedBox(height: 64 * s),
// Jeda Waktu Iqamah Settings Card // Waktu & Durasi Card
_adminCard(s, child: Column( _adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sectionLabel('Jeda Waktu Iqamah (Menit)', s), _sectionLabel('Waktu & Durasi', s),
SizedBox(height: 8 * s), SizedBox(height: 8 * s),
Text( 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), style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
), ),
SizedBox(height: 32 * s), SizedBox(height: 32 * s),
Row( Row(
children: [ 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), 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), 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), SizedBox(height: 16 * s),
Row( Row(
children: [ 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), 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), SizedBox(width: 16 * s),
Expanded(child: SizedBox()), // spacer Expanded(child: SizedBox()), // spacer
], ],
), ),
SizedBox(height: 32 * s), SizedBox(height: 32 * s),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _saveIqomahSettings, onPressed: _saveJadwalTimingSettings,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary, backgroundColor: SacredColors.secondary,
foregroundColor: Colors.black, foregroundColor: Colors.black,
@@ -1300,7 +1464,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)),
), ),
icon: const Icon(Icons.timer), 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<AdminScreen> {
); );
} }
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}) { Widget _buildTextField(String label, TextEditingController ctrl, double s, {int maxLines = 1}) {
return Column( return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -1609,6 +1972,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
), ),
), ),
], ],
),
); );
} }
@@ -1783,6 +2147,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Widget _buildSimulasiTab(double s) { Widget _buildSimulasiTab(double s) {
return SingleChildScrollView( return SingleChildScrollView(
controller: _simulasiScrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1946,7 +2311,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
} }
} }
class _NavButton extends StatelessWidget { class _NavButton extends StatefulWidget {
final String title; final String title;
final dynamic icon; final dynamic icon;
final bool isActive; final bool isActive;
@@ -1961,40 +2326,63 @@ class _NavButton extends StatelessWidget {
required this.onTap, required this.onTap,
}); });
@override
State<_NavButton> createState() => _NavButtonState();
}
class _NavButtonState extends State<_NavButton> {
bool _isFocused = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = scale; final s = widget.scale;
return InkWell( final highlight = widget.isActive || _isFocused;
onTap: onTap,
borderRadius: BorderRadius.circular(SacredRadii.lg), return FocusableActionDetector(
child: Container( onShowFocusHighlight: (value) => setState(() => _isFocused = value),
width: double.infinity, child: InkWell(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s), onTap: widget.onTap,
decoration: BoxDecoration( focusColor: SacredColors.primary.withValues(alpha: 0.22),
color: isActive ? SacredColors.primaryContainer : Colors.transparent, hoverColor: SacredColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(SacredRadii.lg), borderRadius: BorderRadius.circular(SacredRadii.lg),
border: isActive ? Border.all(color: SacredColors.primary.withValues(alpha: 0.3)) : null, child: Container(
), width: double.infinity,
child: Row( padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s),
children: [ decoration: BoxDecoration(
HugeIcon( color: highlight ? SacredColors.primaryContainer : Colors.transparent,
icon: icon, borderRadius: BorderRadius.circular(SacredRadii.lg),
color: isActive ? SacredColors.onPrimaryContainer : SacredColors.onSurfaceVariant, border: highlight
size: 28 * s, ? Border.all(
), color: SacredColors.primary.withValues(alpha: 0.4),
SizedBox(width: 20 * s), width: _isFocused ? 2 : 1,
Expanded( )
child: Text( : null,
title, ),
style: GoogleFonts.plusJakartaSans( child: Row(
fontSize: 18 * s, children: [
fontWeight: FontWeight.bold, HugeIcon(
color: isActive ? SacredColors.onPrimaryContainer : SacredColors.onSurfaceVariant, icon: widget.icon,
letterSpacing: 1 * s, 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,
),
), ),
), ),
), ],
], ),
), ),
), ),
); );

View File

@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/sync_service.dart'; import '../../data/services/sync_service.dart';
@@ -6,6 +9,7 @@ import '../../data/services/sound_service.dart';
import '../../core/enums.dart'; import '../../core/enums.dart';
import '../../core/sacred_tokens.dart'; import '../../core/sacred_tokens.dart';
import '../../providers.dart'; import '../../providers.dart';
import '../admin/admin_screen.dart';
import 'main_screen.dart'; import 'main_screen.dart';
import 'adzan_screen.dart'; import 'adzan_screen.dart';
import 'iqomah_screen.dart'; import 'iqomah_screen.dart';
@@ -23,14 +27,40 @@ class HomeView extends ConsumerStatefulWidget {
} }
class _HomeViewState extends ConsumerState<HomeView> { class _HomeViewState extends ConsumerState<HomeView> {
static const List<LogicalKeyboardKey> _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<LogicalKeyboardKey> _recentKeys = [];
Timer? _comboResetTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAutoSync(); _checkAutoSync();
if (mounted) {
_homeFocusNode.requestFocus();
}
}); });
} }
@override
void dispose() {
_comboResetTimer?.cancel();
_homeFocusNode.dispose();
super.dispose();
}
Future<void> _checkAutoSync() async { Future<void> _checkAutoSync() async {
final schedule = ref.read(todayScheduleProvider); final schedule = ref.read(todayScheduleProvider);
if (schedule == null) { if (schedule == null) {
@@ -45,6 +75,66 @@ class _HomeViewState extends ConsumerState<HomeView> {
} }
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Audio trigger listener // Audio trigger listener
@@ -101,44 +191,49 @@ class _HomeViewState extends ConsumerState<HomeView> {
final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero; final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero;
return Scaffold( return Focus(
backgroundColor: SacredColors.background, autofocus: true,
body: Stack( focusNode: _homeFocusNode,
children: [ onKeyEvent: _handleTvKey,
AnimatedSwitcher( child: Scaffold(
duration: const Duration(milliseconds: 800), backgroundColor: SacredColors.background,
transitionBuilder: (child, animation) { body: Stack(
return FadeTransition(opacity: animation, child: child); children: [
}, AnimatedSwitcher(
child: screen, duration: const Duration(milliseconds: 800),
), transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: screen,
),
if (isSimulating) if (isSimulating)
Positioned( Positioned(
right: 64, right: 64,
bottom: 64, bottom: 64,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero; ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero;
}, },
icon: const Icon(Icons.cancel, color: Colors.white), icon: const Icon(Icons.cancel, color: Colors.white),
label: const Text( label: const Text(
'BATALKAN SIMULASI', 'BATALKAN SIMULASI',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 2, letterSpacing: 2,
color: Colors.white, 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,
),
), ),
), ],
], ),
), ),
); );
} }