Add Android TV admin unlock and focus-driven controls
This commit is contained in:
@@ -56,6 +56,15 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
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<AdminScreen> {
|
||||
_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<AdminScreen> {
|
||||
_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<AdminScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveIqomahSettings() async {
|
||||
Future<void> _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<AdminScreen> {
|
||||
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<AdminScreen> {
|
||||
),
|
||||
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<AdminScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -445,6 +475,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
|
||||
Widget _buildJumatTab(double s) {
|
||||
return SingleChildScrollView(
|
||||
controller: _jumatScrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -567,6 +598,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
|
||||
Widget _buildTampilanTab(double s) {
|
||||
return SingleChildScrollView(
|
||||
controller: _tampilanScrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -591,26 +623,39 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
children: [
|
||||
_sectionLabel('Tipografi & Skala Teks', s),
|
||||
SizedBox(height: 12 * s),
|
||||
DropdownButtonFormField<int>(
|
||||
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<int>(
|
||||
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<AdminScreen> {
|
||||
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<AdminScreen> {
|
||||
}
|
||||
|
||||
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<AdminScreen> {
|
||||
|
||||
|
||||
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<AdminScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1086,6 +1145,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
final displayedHijri = ref.watch(hijriDateProvider).valueOrNull;
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _jadwalScrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1259,39 +1319,143 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
|
||||
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<AdminScreen> {
|
||||
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<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}) {
|
||||
return Column(
|
||||
return _scrollAware(
|
||||
controller: _scrollControllerForTab(_selectedTab),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
@@ -1609,6 +1972,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1783,6 +2147,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
|
||||
Widget _buildSimulasiTab(double s) {
|
||||
return SingleChildScrollView(
|
||||
controller: _simulasiScrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1946,7 +2311,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<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
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkAutoSync();
|
||||
if (mounted) {
|
||||
_homeFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_comboResetTimer?.cancel();
|
||||
_homeFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkAutoSync() async {
|
||||
final schedule = ref.read(todayScheduleProvider);
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// Audio trigger listener
|
||||
@@ -101,44 +191,49 @@ class _HomeViewState extends ConsumerState<HomeView> {
|
||||
|
||||
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,
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user