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 _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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user