From 8774735e38bb2f63b63a4375be9b3672a3403916 Mon Sep 17 00:00:00 2001 From: dwindown Date: Tue, 31 Mar 2026 14:00:29 +0700 Subject: [PATCH] Refine TV admin navigation and simulation flow --- lib/features/admin/admin_screen.dart | 4605 ++++++++++++++++++-------- lib/features/home/home_view.dart | 86 +- 2 files changed, 3211 insertions(+), 1480 deletions(-) diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index b406503..c037bf3 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hugeicons/hugeicons.dart'; @@ -12,7 +14,14 @@ import 'package:file_picker/file_picker.dart'; import 'dart:io'; class AdminScreen extends ConsumerStatefulWidget { - const AdminScreen({super.key}); + final int initialTab; + final bool focusSelectedTabOnOpen; + + const AdminScreen({ + super.key, + this.initialTab = 0, + this.focusSelectedTabOnOpen = false, + }); @override ConsumerState createState() => _AdminScreenState(); @@ -66,11 +75,59 @@ class _AdminScreenState extends ConsumerState { final _tampilanScrollController = ScrollController(); final _jumatScrollController = ScrollController(); final _simulasiScrollController = ScrollController(); + late final FocusNode _identityEntryFocusNode; + late final FocusNode _tampilanEntryFocusNode; + late final FocusNode _jumatEntryFocusNode; + late final FocusNode _simulasiEntryFocusNode; + late final List _navFocusNodes; + late final List _jadwalFocusNodes; + late final List _identityFocusNodes; + late final List _jumatFocusNodes; + late final List _simulasiFocusNodes; + final Map _tampilanFocusNodes = {}; + Timer? _identityAutoSaveTimer; + Timer? _tampilanAutoSaveTimer; + Timer? _jumatAutoSaveTimer; + Timer? _jadwalAutoSaveTimer; + Timer? _statusBadgeTimer; + String? _statusBadgeMessage; + bool _statusBadgeIsError = false; int _hijriOffsetDays = 0; @override void initState() { super.initState(); + _selectedTab = widget.initialTab.clamp(0, 4); + _identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry'); + _tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry'); + _jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry'); + _simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry'); + _navFocusNodes = List.generate( + 5, + (index) => FocusNode(debugLabel: 'admin_nav_$index'), + ); + _identityFocusNodes = [ + _identityEntryFocusNode, + ...List.generate( + 2, + (index) => FocusNode(debugLabel: 'identity_row_${index + 1}'), + ), + ]; + _jumatFocusNodes = [ + _jumatEntryFocusNode, + FocusNode(debugLabel: 'jumat_row_1'), + ]; + _simulasiFocusNodes = [ + _simulasiEntryFocusNode, + ...List.generate( + 6, + (index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'), + ), + ]; + _jadwalFocusNodes = List.generate( + 11, + (index) => FocusNode(debugLabel: 'jadwal_row_$index'), + ); final settings = ref.read(settingsProvider); _masjidNameCtrl.text = settings.masjidName; _masjidAddressCtrl.text = settings.masjidAddress; @@ -110,9 +167,30 @@ class _AdminScreenState extends ConsumerState { _blankJumatCtrl.text = settings.blankScreenJumat.toString(); _hijriOffsetDays = settings.hijriOffsetDays; - // Update preview live as admin types - _khatibCtrl.addListener(() => setState(() {})); - _imamCtrl.addListener(() => setState(() {})); + _mainDurCtrl.addListener(_queueTampilanAutoSave); + _slideDurCtrl.addListener(_queueTampilanAutoSave); + _unsplashKeywordCtrl.addListener(_queueTampilanAutoSave); + _unsplashRotationCtrl.addListener(_queueTampilanAutoSave); + _masjidNameCtrl.addListener(_queueIdentityAutoSave); + _masjidAddressCtrl.addListener(_queueIdentityAutoSave); + _khatibCtrl.addListener(() { + if (!mounted) return; + setState(() {}); + _queueJumatAutoSave(); + }); + _imamCtrl.addListener(() { + if (!mounted) return; + setState(() {}); + _queueJumatAutoSave(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (widget.focusSelectedTabOnOpen) { + _focusEntryForTab(_selectedTab); + } else { + _focusNavTab(_selectedTab); + } + }); } @override @@ -139,10 +217,36 @@ class _AdminScreenState extends ConsumerState { _tampilanScrollController.dispose(); _jumatScrollController.dispose(); _simulasiScrollController.dispose(); + _tampilanEntryFocusNode.dispose(); + _identityAutoSaveTimer?.cancel(); + _tampilanAutoSaveTimer?.cancel(); + _jumatAutoSaveTimer?.cancel(); + _jadwalAutoSaveTimer?.cancel(); + _statusBadgeTimer?.cancel(); + for (final node in _navFocusNodes) { + node.dispose(); + } + for (final node in _identityFocusNodes) { + node.dispose(); + } + for (final node in _jumatFocusNodes) { + node.dispose(); + } + for (final node in _simulasiFocusNodes) { + node.dispose(); + } + for (final node in _jadwalFocusNodes) { + node.dispose(); + } + for (final node in _tampilanFocusNodes.values) { + node.dispose(); + } super.dispose(); } - Future _saveIdentity() async { + Future _saveIdentity({ + String message = 'Identitas masjid otomatis tersimpan', + }) async { await ref.read(settingsProvider.notifier).updateSettings((s) { s.masjidName = _masjidNameCtrl.text.trim(); s.masjidAddress = _masjidAddressCtrl.text.trim(); @@ -150,17 +254,21 @@ class _AdminScreenState extends ConsumerState { return s; }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Pengaturan berhasil disimpan', - style: GoogleFonts.manrope()), - backgroundColor: SacredColors.primaryContainer, - ), - ); + _showStatusBadge(message); } } - Future _saveTampilan() async { + void _queueIdentityAutoSave() { + _identityAutoSaveTimer?.cancel(); + _identityAutoSaveTimer = Timer( + const Duration(milliseconds: 450), + () => _saveIdentity(), + ); + } + + Future _saveTampilan({ + String message = 'Pengaturan tampilan otomatis tersimpan', + }) async { await ref.read(settingsProvider.notifier).updateSettings((s) { s.textScaleIndex = _textScaleIndex; s.slideshowImages = List.from(_slideshowImages); @@ -179,16 +287,23 @@ class _AdminScreenState extends ConsumerState { return s; }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Pengaturan Tampilan berhasil disimpan', style: GoogleFonts.manrope()), - backgroundColor: SacredColors.primaryContainer, - ), - ); + _showStatusBadge(message); } } - Future _saveJadwalTimingSettings() async { + void _queueTampilanAutoSave({ + String message = 'Pengaturan tampilan otomatis tersimpan', + }) { + _tampilanAutoSaveTimer?.cancel(); + _tampilanAutoSaveTimer = Timer( + const Duration(milliseconds: 450), + () => _saveTampilan(message: message), + ); + } + + Future _saveJadwalSettings({ + String message = 'Pengaturan jadwal otomatis tersimpan', + }) async { await ref.read(settingsProvider.notifier).updateSettings((s) { s.preAdzanLead = int.tryParse(_preAdzanLeadCtrl.text.trim()) ?? 10; s.blankScreenNormal = int.tryParse(_blankNormalCtrl.text.trim()) ?? 15; @@ -198,42 +313,63 @@ class _AdminScreenState extends ConsumerState { s.iqomahAshar = int.tryParse(_iqomahAsharCtrl.text.trim()) ?? 10; s.iqomahMaghrib = int.tryParse(_iqomahMaghribCtrl.text.trim()) ?? 10; s.iqomahIsya = int.tryParse(_iqomahIsyaCtrl.text.trim()) ?? 10; - return s; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Pengaturan jadwal dan durasi berhasil disimpan', - style: GoogleFonts.manrope(), - ), - backgroundColor: SacredColors.primaryContainer, - ), - ); - } - } - - Future _saveHijriSettings() async { - await ref.read(settingsProvider.notifier).updateSettings((s) { s.hijriOffsetDays = _hijriOffsetDays; return s; }); if (mounted) { ref.invalidate(hijriDateProvider); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Offset Hijriah disimpan: ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari', - style: GoogleFonts.manrope(), - ), - backgroundColor: SacredColors.primaryContainer, - ), - ); + _showStatusBadge(message); } } + void _queueJadwalAutoSave({ + String message = 'Pengaturan jadwal otomatis tersimpan', + }) { + _jadwalAutoSaveTimer?.cancel(); + _jadwalAutoSaveTimer = Timer( + const Duration(milliseconds: 450), + () => _saveJadwalSettings(message: message), + ); + } + + Future _saveJumat({ + String message = 'Pengaturan Jumat otomatis tersimpan', + }) async { + await ref.read(settingsProvider.notifier).updateSettings((s) { + s.khatibName = _khatibCtrl.text.trim(); + s.imamName = _imamCtrl.text.trim(); + return s; + }); + if (mounted) { + _showStatusBadge(message); + } + } + + void _queueJumatAutoSave() { + _jumatAutoSaveTimer?.cancel(); + _jumatAutoSaveTimer = Timer( + const Duration(milliseconds: 450), + () => _saveJumat(), + ); + } + + void _showStatusBadge(String message, {bool isError = false}) { + if (!mounted) return; + _statusBadgeTimer?.cancel(); + setState(() { + _statusBadgeMessage = message; + _statusBadgeIsError = isError; + }); + _statusBadgeTimer = Timer(const Duration(seconds: 2), () { + if (!mounted) return; + setState(() { + _statusBadgeMessage = null; + _statusBadgeIsError = false; + }); + }); + } + Future _syncData() async { setState(() => _isSyncing = true); final success = await SyncService.instance.syncMonthlyData(); @@ -242,124 +378,454 @@ class _AdminScreenState extends ConsumerState { if (mounted) { ref.invalidate(todayScheduleProvider); ref.invalidate(scheduleCacheStatusProvider); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success ? 'Sinkronisasi jadwal berhasil' : 'Sinkronisasi gagal. Periksa koneksi internet.', - style: GoogleFonts.manrope()), - backgroundColor: success ? SacredColors.primaryContainer : SacredColors.errorContainer, - ), + + _showStatusBadge( + success + ? 'Sinkronisasi jadwal berhasil' + : 'Sinkronisasi gagal. Periksa koneksi internet.', + isError: !success, ); } } Future _showCitySearchDialog(double s) async { final queryCtrl = TextEditingController(); + final queryFocusNode = FocusNode(debugLabel: 'city_query'); + final searchFocusNode = FocusNode(debugLabel: 'city_search'); + final resultsScrollController = ScrollController(); + final resultFocusNodes = []; List> results = []; bool isSearching = false; - await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder( - builder: (context, setDialogState) { - return Dialog( - backgroundColor: SacredColors.surfaceContainerLowest, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.xl)), - child: Container( - width: 800 * s, - height: 600 * s, - padding: EdgeInsets.all(40 * s), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Cari Kota / Kabupaten', - style: GoogleFonts.plusJakartaSans( - fontSize: 32 * s, fontWeight: FontWeight.bold, color: SacredColors.primary)), - SizedBox(height: 24 * s), - Row( - children: [ - Expanded( - child: TextField( - controller: queryCtrl, - style: GoogleFonts.manrope(fontSize: 24 * s, color: SacredColors.onSurface), - decoration: InputDecoration( - hintText: 'Misal: Yogyakarta', - filled: true, - fillColor: SacredColors.surfaceContainerLow, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.md)), + try { + await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setDialogState) { + Future selectCity(Map city) async { + final id = city['id'].toString(); + final loc = city['lokasi'].toString(); + + await ref.read(settingsProvider.notifier).updateSettings((s) { + s.cityIdApi = id; + s.cityDisplayName = loc; + return s; + }); + if (!mounted || !ctx.mounted) { + return; + } + setState(() { + _cityCtrl.text = '$loc ($id)'; + }); + _showStatusBadge( + 'Lokasi jadwal otomatis tersimpan', + ); + Navigator.pop(ctx); + } + + Future runSearch() async { + final query = queryCtrl.text.trim(); + if (query.isEmpty) return; + setDialogState(() => isSearching = true); + final res = await MyQuranSholatService.instance.searchCity(query); + for (final node in resultFocusNodes) { + node.dispose(); + } + resultFocusNodes + ..clear() + ..addAll( + List.generate( + res.length, + (index) => FocusNode(debugLabel: 'city_result_$index'), + ), + ); + setDialogState(() { + results = res; + isSearching = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!ctx.mounted) return; + if (res.isNotEmpty) { + resultFocusNodes.first.requestFocus(); + } else { + searchFocusNode.requestFocus(); + } + }); + } + + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + autofocus: true, + canRequestFocus: false, + onKeyEvent: (node, event) { + if ((event is KeyDownEvent || event is KeyRepeatEvent) && + event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(ctx); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Dialog( + backgroundColor: SacredColors.surfaceContainerLowest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(SacredRadii.xl), + ), + child: Container( + width: 820 * s, + height: 680 * s, + padding: EdgeInsets.all(40 * s), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cari Kota / Kabupaten', + style: GoogleFonts.plusJakartaSans( + fontSize: 32 * s, + fontWeight: FontWeight.bold, + color: SacredColors.primary, ), ), - ), - SizedBox(width: 16 * s), - ElevatedButton.icon( - onPressed: () async { - final query = queryCtrl.text.trim(); - if (query.isEmpty) return; - setDialogState(() => isSearching = true); - final res = await MyQuranSholatService.instance.searchCity(query); - setDialogState(() { - results = res; - isSearching = false; - }); - }, - icon: isSearching - ? SizedBox(width: 20*s, height: 20*s, child: const CircularProgressIndicator(color: SacredColors.onPrimary, strokeWidth: 2)) - : const HugeIcon(icon: HugeIcons.strokeRoundedSearch01, color: SacredColors.onPrimary), - label: Text('CARI', style: GoogleFonts.plusJakartaSans(fontSize: 20*s, fontWeight: FontWeight.bold)), - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.primary, - foregroundColor: SacredColors.onPrimary, - padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 24 * s), + SizedBox(height: 12 * s), + Text( + 'Gunakan OK untuk edit kata kunci, lalu tekan tombol cari.', + style: GoogleFonts.manrope( + fontSize: 16 * s, + color: SacredColors.onSurfaceVariant, + ), ), - ), - ], - ), - SizedBox(height: 32 * s), - Expanded( - child: results.isEmpty && !isSearching - ? Center(child: Text('Tidak ada hasil', style: GoogleFonts.manrope(fontSize: 20 * s, color: SacredColors.onSurfaceVariant))) - : ListView.builder( - itemCount: results.length, - itemBuilder: (context, index) { - final city = results[index]; - return Padding( - padding: EdgeInsets.only(bottom: 8 * s), - child: ListTile( - title: Text(city['lokasi'] ?? '', style: GoogleFonts.plusJakartaSans(fontSize: 24 * s, color: SacredColors.onSurface)), - subtitle: Text('ID: ${city['id']}', style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.primary)), - tileColor: SacredColors.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.sm)), - contentPadding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s), - onTap: () async { - final id = city['id'].toString(); - final loc = city['lokasi'].toString(); - - await ref.read(settingsProvider.notifier).updateSettings((s) { - s.cityIdApi = id; - s.cityDisplayName = loc; - return s; - }); - if (!mounted || !ctx.mounted) return; - setState(() { - _cityCtrl.text = '$loc ($id)'; - }); - Navigator.pop(ctx); - }, + SizedBox(height: 24 * s), + _TvEditableTextTile( + scale: s, + label: 'Kata Kunci Kota / Kabupaten', + controller: queryCtrl, + focusNode: queryFocusNode, + onEditComplete: () { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (ctx.mounted) { + searchFocusNode.requestFocus(); + } + }); + }, + ), + SizedBox(height: 16 * s), + Focus( + focusNode: searchFocusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent && + event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowUp) { + queryFocusNode.requestFocus(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + if (resultFocusNodes.isNotEmpty) { + resultFocusNodes.first.requestFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.select) { + runSearch(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: ListenableBuilder( + listenable: searchFocusNode, + builder: (context, child) { + final hasFocus = searchFocusNode.hasFocus; + return AnimatedScale( + scale: hasFocus ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: InkWell( + onTap: runSearch, + borderRadius: + BorderRadius.circular(SacredRadii.lg), + child: AnimatedContainer( + duration: + const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: 28 * s, + vertical: 22 * s, + ), + decoration: BoxDecoration( + color: hasFocus + ? SacredColors.primary + : SacredColors.secondary, + borderRadius: BorderRadius.circular( + SacredRadii.lg, + ), + border: Border.all( + color: hasFocus + ? SacredColors.primary + : Colors.transparent, + width: hasFocus ? 3 : 0, + ), + boxShadow: hasFocus + ? [ + BoxShadow( + color: SacredColors.primary + .withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + isSearching + ? SizedBox( + width: 20 * s, + height: 20 * s, + child: + CircularProgressIndicator( + color: hasFocus + ? SacredColors.onPrimary + : SacredColors.onSecondary, + strokeWidth: 2, + ), + ) + : HugeIcon( + icon: HugeIcons + .strokeRoundedSearch01, + color: hasFocus + ? SacredColors.onPrimary + : SacredColors.onSecondary, + ), + SizedBox(width: 12 * s), + Text( + 'CARI KOTA / KABUPATEN', + style: GoogleFonts.plusJakartaSans( + fontSize: 20 * s, + fontWeight: FontWeight.bold, + color: hasFocus + ? SacredColors.onPrimary + : SacredColors.onSecondary, + ), + ), + ], + ), + ), ), ); }, ), + ), + SizedBox(height: 24 * s), + Expanded( + child: results.isEmpty && !isSearching + ? Center( + child: Text( + 'Tidak ada hasil', + style: GoogleFonts.manrope( + fontSize: 20 * s, + color: SacredColors.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + controller: resultsScrollController, + itemCount: results.length, + itemBuilder: (context, index) { + final city = results[index]; + return Padding( + padding: + EdgeInsets.only(bottom: 10 * s), + child: Focus( + focusNode: resultFocusNodes[index], + onFocusChange: (value) { + if (!value) return; + final focusContext = + resultFocusNodes[index].context; + if (focusContext != null) { + Scrollable.ensureVisible( + focusContext, + duration: const Duration( + milliseconds: 140, + ), + alignment: 0.25, + curve: Curves.easeOutCubic, + ); + } + }, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent && + event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key == + LogicalKeyboardKey.enter || + key == + LogicalKeyboardKey.select) { + selectCity(city); + return KeyEventResult.handled; + } + if (key == + LogicalKeyboardKey.arrowUp) { + if (index == 0) { + searchFocusNode.requestFocus(); + } else { + resultFocusNodes[index - 1] + .requestFocus(); + } + return KeyEventResult.handled; + } + if (key == + LogicalKeyboardKey.arrowDown) { + if (index < + resultFocusNodes.length - 1) { + resultFocusNodes[index + 1] + .requestFocus(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: ListenableBuilder( + listenable: resultFocusNodes[index], + builder: (context, child) { + final hasFocus = + resultFocusNodes[index] + .hasFocus; + return AnimatedScale( + scale: hasFocus ? 1.01 : 1.0, + duration: const Duration( + milliseconds: 140, + ), + curve: Curves.easeOutCubic, + child: InkWell( + onTap: () => selectCity(city), + borderRadius: + BorderRadius.circular( + SacredRadii.md, + ), + child: AnimatedContainer( + duration: const Duration( + milliseconds: 140, + ), + curve: + Curves.easeOutCubic, + padding: + EdgeInsets.symmetric( + horizontal: 24 * s, + vertical: 16 * s, + ), + decoration: BoxDecoration( + color: hasFocus + ? SacredColors + .surfaceContainerLow + : SacredColors + .surfaceContainerLowest, + borderRadius: + BorderRadius.circular( + SacredRadii.md, + ), + border: Border.all( + color: hasFocus + ? SacredColors + .primary + .withValues( + alpha: 0.95, + ) + : SacredColors + .outlineVariant + .withValues( + alpha: 0.35, + ), + width: hasFocus ? 3 : 1, + ), + boxShadow: hasFocus + ? [ + BoxShadow( + color: SacredColors + .primary + .withValues( + alpha: + 0.28, + ), + blurRadius: + 24 * s, + spreadRadius: + 2 * s, + ), + ] + : null, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + city['lokasi'] ?? '', + style: GoogleFonts + .plusJakartaSans( + fontSize: 24 * s, + fontWeight: + FontWeight.w700, + color: SacredColors + .onSurface, + ), + ), + SizedBox( + height: 6 * s, + ), + Text( + 'ID: ${city['id']}', + style: GoogleFonts + .manrope( + fontSize: 18 * s, + color: hasFocus + ? SacredColors + .primary + : SacredColors + .onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + }, + ), + ), + ], + ), ), - ], + ), ), - ), - ); - } - ); - }, - ); + ); + }, + ); + }, + ); + } finally { + queryCtrl.dispose(); + queryFocusNode.dispose(); + searchFocusNode.dispose(); + resultsScrollController.dispose(); + for (final node in resultFocusNodes) { + node.dispose(); + } + } } @override @@ -381,6 +847,56 @@ class _AdminScreenState extends ConsumerState { ), ), iconTheme: const IconThemeData(color: SacredColors.primary), + actions: [ + Padding( + padding: EdgeInsets.only(right: 24 * s), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: _statusBadgeMessage == null + ? const SizedBox.shrink() + : Container( + key: ValueKey(_statusBadgeMessage), + padding: EdgeInsets.symmetric( + horizontal: 16 * s, + vertical: 8 * s, + ), + decoration: BoxDecoration( + color: _statusBadgeIsError + ? SacredColors.errorContainer + : SacredColors.primaryContainer, + borderRadius: BorderRadius.circular(SacredRadii.full), + border: Border.all( + color: _statusBadgeIsError + ? SacredColors.error + : SacredColors.primary.withValues(alpha: 0.85), + width: 1.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _statusBadgeIsError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + color: Colors.white, + size: 16 * s, + ), + SizedBox(width: 8 * s), + Text( + _statusBadgeMessage!, + style: GoogleFonts.manrope( + fontSize: 13 * s, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ], ), body: FocusTraversalGroup( policy: ReadingOrderTraversalPolicy(), @@ -400,6 +916,11 @@ class _AdminScreenState extends ConsumerState { icon: HugeIcons.strokeRoundedHome01, isActive: _selectedTab == 0, scale: s, + focusNode: _navFocusNodes[0], + onFocusChange: (focused) { + if (focused) _setSelectedTab(0); + }, + onKeyEvent: (node, event) => _handleNavKey(0, event), onTap: () => setState(() => _selectedTab = 0), ), SizedBox(height: 16 * s), @@ -408,6 +929,11 @@ class _AdminScreenState extends ConsumerState { icon: HugeIcons.strokeRoundedCalendar01, isActive: _selectedTab == 1, scale: s, + focusNode: _navFocusNodes[1], + onFocusChange: (focused) { + if (focused) _setSelectedTab(1); + }, + onKeyEvent: (node, event) => _handleNavKey(1, event), onTap: () => setState(() => _selectedTab = 1), ), SizedBox(height: 16 * s), @@ -416,6 +942,11 @@ class _AdminScreenState extends ConsumerState { icon: HugeIcons.strokeRoundedImage01, isActive: _selectedTab == 2, scale: s, + focusNode: _navFocusNodes[2], + onFocusChange: (focused) { + if (focused) _setSelectedTab(2); + }, + onKeyEvent: (node, event) => _handleNavKey(2, event), onTap: () => setState(() => _selectedTab = 2), ), SizedBox(height: 16 * s), @@ -424,6 +955,11 @@ class _AdminScreenState extends ConsumerState { icon: HugeIcons.strokeRoundedCalendar01, isActive: _selectedTab == 3, scale: s, + focusNode: _navFocusNodes[3], + onFocusChange: (focused) { + if (focused) _setSelectedTab(3); + }, + onKeyEvent: (node, event) => _handleNavKey(3, event), onTap: () => setState(() => _selectedTab = 3), ), SizedBox(height: 16 * s), @@ -432,6 +968,11 @@ class _AdminScreenState extends ConsumerState { icon: HugeIcons.strokeRoundedClock01, isActive: _selectedTab == 4, scale: s, + focusNode: _navFocusNodes[4], + onFocusChange: (focused) { + if (focused) _setSelectedTab(4); + }, + onKeyEvent: (node, event) => _handleNavKey(4, event), onTap: () => setState(() => _selectedTab = 4), ), ], @@ -459,26 +1000,312 @@ class _AdminScreenState extends ConsumerState { ); } - Future _saveJumat() async { - await ref.read(settingsProvider.notifier).updateSettings((s) { - s.khatibName = _khatibCtrl.text.trim(); - s.imamName = _imamCtrl.text.trim(); - return s; + void _setSelectedTab(int index) { + if (_selectedTab == index) return; + setState(() => _selectedTab = index); + } + + void _traceNav(String message) { + assert(() { + debugPrint('[TV NAV] $message'); + return true; + }()); + } + + void _focusNavTab(int index) { + if (index < 0 || index >= _navFocusNodes.length) return; + _traceNav('focus nav[$index]'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _navFocusNodes[index].requestFocus(); + } }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Data Jumat berhasil disimpan', style: GoogleFonts.manrope()), - backgroundColor: SacredColors.primaryContainer, - ), - ); + } + + void _focusIdentityRow(int index) { + if (_selectedTab != 0) return; + if (index < 0 || index >= _identityFocusNodes.length) return; + _traceNav('focus identitas[$index]'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _identityFocusNodes[index].requestFocus(); + } + }); + } + + void _focusJumatRow(int index) { + if (_selectedTab != 3) return; + if (index < 0 || index >= _jumatFocusNodes.length) return; + _traceNav('focus jumat[$index]'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _jumatFocusNodes[index].requestFocus(); + } + }); + } + + void _focusSimulasiRow(int index) { + if (_selectedTab != 4) return; + if (index < 0 || index >= _simulasiFocusNodes.length) return; + _traceNav('focus simulasi[$index]'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _simulasiFocusNodes[index].requestFocus(); + } + }); + } + + void _focusJadwalRow(int index) { + if (_selectedTab != 1) return; + if (index < 0 || index >= _jadwalFocusNodes.length) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _jadwalFocusNodes[index].requestFocus(); + } + }); + } + + FocusNode _tampilanFocusNode(int index) { + if (index == 0) { + return _tampilanEntryFocusNode; } + return _tampilanFocusNodes.putIfAbsent( + index, + () => FocusNode(debugLabel: 'tampilan_row_$index'), + ); + } + + int _tampilanRowCount() { + var count = 0; + count += 7; + if (_useUnsplash) { + count += 2; + } + if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) { + count += 1; + } + count += 1; + count += 1; + count += _slideshowImages.length; + count += 1; + count += _runningTexts.length * 3; + count += 1; + return count; + } + + void _focusTampilanRow(int index) { + if (_selectedTab != 2) return; + final max = _tampilanRowCount(); + if (index < 0 || index >= max) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _tampilanFocusNode(index).requestFocus(); + } + }); + } + + void _focusEntryForTab(int index) { + final FocusNode? target; + switch (index) { + case 0: + _focusIdentityRow(0); + return; + case 1: + _focusJadwalRow(0); + return; + case 2: + _focusTampilanRow(0); + return; + case 3: + _focusJumatRow(0); + return; + case 4: + _focusSimulasiRow(0); + return; + default: + target = null; + } + if (target == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + target!.requestFocus(); + } + }); + } + + KeyEventResult _handleNavKey(int index, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowUp) { + _focusNavTab(index - 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + _focusNavTab(index + 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter) { + _focusEntryForTab(index); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + KeyEventResult _handleJadwalActionKey( + int index, + KeyEvent event, { + required VoidCallback onActivate, + }) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowUp) { + _focusJadwalRow(index - 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + _focusJadwalRow(index + 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowLeft) { + _focusNavTab(_selectedTab); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { + onActivate(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + KeyEventResult _handleSimpleTabKey(KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _focusNavTab(_selectedTab); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleIdentityActionKey( + int index, + KeyEvent event, { + required VoidCallback onActivate, + }) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + _traceNav('identitas[$index] key=$key'); + if (key == LogicalKeyboardKey.arrowUp) { + _focusIdentityRow(index - 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + _focusIdentityRow(index + 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowLeft) { + _focusNavTab(_selectedTab); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { + onActivate(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleTampilanActionKey( + int index, + KeyEvent event, { + required VoidCallback onActivate, + }) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowUp) { + _focusTampilanRow(index - 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + _focusTampilanRow(index + 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowLeft) { + _focusNavTab(_selectedTab); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { + onActivate(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleSimulasiActionKey( + int index, + KeyEvent event, { + required VoidCallback onActivate, + }) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + _traceNav('simulasi[$index] key=$key'); + if (key == LogicalKeyboardKey.arrowUp) { + _focusSimulasiRow(index - 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + _focusSimulasiRow(index + 1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowLeft) { + _focusNavTab(_selectedTab); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { + onActivate(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } Widget _buildJumatTab(double s) { - return SingleChildScrollView( - controller: _jumatScrollController, - child: Column( + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) => _handleSimpleTabKey(event), + child: SingleChildScrollView( + controller: _jumatScrollController, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -503,9 +1330,23 @@ class _AdminScreenState extends ConsumerState { style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 24 * s), - _buildTextField('Nama Khatib Minggu Ini', _khatibCtrl, s), + _buildTextField( + 'Nama Khatib Minggu Ini', + _khatibCtrl, + s, + focusNode: _jumatFocusNodes[0], + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveDown: () => _focusJumatRow(1), + ), SizedBox(height: 16 * s), - _buildTextField('Nama Imam Minggu Ini', _imamCtrl, s), + _buildTextField( + 'Nama Imam Minggu Ini', + _imamCtrl, + s, + focusNode: _jumatFocusNodes[1], + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJumatRow(0), + ), SizedBox(height: 32 * s), // Preview chip @@ -542,22 +1383,6 @@ class _AdminScreenState extends ConsumerState { ), SizedBox(height: 24 * s), ], - - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: _saveJumat, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.secondary, - foregroundColor: Colors.black, - padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 20 * s), - textStyle: TextStyle(fontSize: 18 * s, fontWeight: FontWeight.bold), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), - ), - icon: const Icon(Icons.save_rounded), - label: const Text('SIMPAN DATA JUMAT'), - ), - ), ], )), @@ -578,6 +1403,8 @@ class _AdminScreenState extends ConsumerState { )), ], ), + ), + ), ); } @@ -602,9 +1429,49 @@ class _AdminScreenState extends ConsumerState { } Widget _buildTampilanTab(double s) { - return SingleChildScrollView( - controller: _tampilanScrollController, - child: Column( + var row = 0; + final textScaleRow = row++; + final mainDurationRow = row++; + final slideDurationRow = row++; + final scaleLabelRow = row++; + final scaleBodyRow = row++; + final scaleRunningRow = row++; + final useUnsplashRow = row++; + int? unsplashKeywordRow; + int? unsplashRotationRow; + if (_useUnsplash) { + unsplashKeywordRow = row++; + unsplashRotationRow = row++; + } + int? removeBrandedBgRow; + if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) { + removeBrandedBgRow = row++; + } + final pickBrandedBgRow = row++; + final addSlideshowImageRow = row++; + final slideshowDeleteRows = List.generate( + _slideshowImages.length, + (_) => row++, + ); + final marqueeModeRow = row++; + final runningTextTextRows = []; + final runningTextDurationRows = []; + final runningTextDeleteRows = []; + for (var i = 0; i < _runningTexts.length; i++) { + runningTextTextRows.add(row++); + runningTextDurationRows.add(row++); + runningTextDeleteRows.add(row++); + } + final addRunningTextRow = row++; + + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) => _handleSimpleTabKey(event), + child: SingleChildScrollView( + controller: _tampilanScrollController, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -616,304 +1483,383 @@ class _AdminScreenState extends ConsumerState { ), ), SizedBox(height: 48 * s), - - // ── Row 1: General settings + Background ── - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left: Typography & Timers - Expanded( - child: _adminCard(s, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionLabel('Tipografi & Skala Teks', s), - SizedBox(height: 12 * s), - _buildSegmentedControl( - s: s, - child: SegmentedButton( - segments: const [ - ButtonSegment(value: 0, label: Text('Kecil')), - ButtonSegment(value: 1, label: Text('Normal')), - ButtonSegment(value: 2, label: Text('Besar')), - ], - selected: {_textScaleIndex}, - onSelectionChanged: (val) { - setState(() => _textScaleIndex = val.first); - }, - ), + _adminCard( + s, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Tipografi & Skala Teks', s), + SizedBox(height: 12 * s), + _buildTvChoiceField( + s: s, + rowIndex: textScaleRow, + label: 'Skala Teks Global', + options: const ['Kecil', 'Normal', 'Besar'], + selectedIndex: _textScaleIndex, + onChanged: (index) { + setState(() => _textScaleIndex = index); + _queueTampilanAutoSave(); + }, + ), + SizedBox(height: 28 * s), + _buildTvIntStepperField( + s: s, + label: 'Durasi Layar Utama', + focusNode: _tampilanFocusNode(mainDurationRow), + controller: _mainDurCtrl, + fallback: 15, + min: 5, + max: 120, + suffix: 'detik', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(textScaleRow), + onMoveDown: () => _focusTampilanRow(slideDurationRow), + ), + SizedBox(height: 24 * s), + _buildTvIntStepperField( + s: s, + label: 'Durasi Tiap Slideshow', + focusNode: _tampilanFocusNode(slideDurationRow), + controller: _slideDurCtrl, + fallback: 10, + min: 5, + max: 120, + suffix: 'detik', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(mainDurationRow), + onMoveDown: () => _focusTampilanRow(scaleLabelRow), + ), + SizedBox(height: 40 * s), + _sectionLabel('Ukuran Teks Per Kelompok', s), + SizedBox(height: 8 * s), + Text( + 'Kontrol ukuran teks secara spesifik per kelompok, terlepas dari skala global di atas.', + style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), + ), + SizedBox(height: 20 * s), + _scaleSlider( + s: s, + label: 'Label Shalat (Nama: SUBUH, DZUHUR…)', + focusNode: _tampilanFocusNode(scaleLabelRow), + value: _scaleCardLabel, + onChanged: (v) { + setState(() => _scaleCardLabel = v); + _queueTampilanAutoSave(); + }, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(slideDurationRow), + onMoveDown: () => _focusTampilanRow(scaleBodyRow), + ), + SizedBox(height: 16 * s), + _scaleSlider( + s: s, + label: 'Waktu & Iqamah pada kartu jadwal', + focusNode: _tampilanFocusNode(scaleBodyRow), + value: _scaleCardBody, + onChanged: (v) { + setState(() => _scaleCardBody = v); + _queueTampilanAutoSave(); + }, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(scaleLabelRow), + onMoveDown: () => _focusTampilanRow(scaleRunningRow), + ), + SizedBox(height: 16 * s), + _scaleSlider( + s: s, + label: 'Teks Berjalan (Running Text)', + focusNode: _tampilanFocusNode(scaleRunningRow), + value: _scaleRunningText, + onChanged: (v) { + setState(() => _scaleRunningText = v); + _queueTampilanAutoSave(); + }, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(scaleBodyRow), + onMoveDown: () => _focusTampilanRow(useUnsplashRow), + ), + SizedBox(height: 40 * s), + _sectionLabel('Background Layar Utama (Unsplash)', s), + SizedBox(height: 12 * s), + _buildTvBoolField( + s: s, + rowIndex: useUnsplashRow, + label: 'Gunakan Foto Unsplash API', + value: _useUnsplash, + onChanged: (val) { + setState(() => _useUnsplash = val); + _queueTampilanAutoSave(); + }, + trueLabel: 'Aktif', + falseLabel: 'Nonaktif', + ), + if (_useUnsplash) ...[ + SizedBox(height: 12 * s), + _buildTextField( + 'Kata Kunci (Contoh: mosque, architecture)', + _unsplashKeywordCtrl, + s, + focusNode: _tampilanFocusNode(unsplashKeywordRow!), + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(useUnsplashRow), + onMoveDown: () => _focusTampilanRow(unsplashRotationRow!), + ), + SizedBox(height: 12 * s), + _buildTvIntStepperField( + s: s, + label: 'Rotasi Foto', + focusNode: _tampilanFocusNode(unsplashRotationRow!), + controller: _unsplashRotationCtrl, + fallback: 6, + min: 1, + max: 24, + suffix: 'jam', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(unsplashKeywordRow!), + onMoveDown: () => _focusTampilanRow( + removeBrandedBgRow ?? pickBrandedBgRow, ), - SizedBox(height: 28 * s), - _buildTvIntStepperField( - s: s, - label: 'Durasi Layar Utama', - controller: _mainDurCtrl, - fallback: 15, - min: 5, - max: 120, - suffix: 'detik', - fastStep: 10, + ), + ], + ], + ), + ), + SizedBox(height: 24 * s), + _adminCard( + s, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Foto Latar Utama (Branding Masjid)', s), + SizedBox(height: 16 * s), + if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(SacredRadii.md), + child: Image.file( + File(_brandedBgImage!), + height: 180 * s, + width: double.infinity, + fit: BoxFit.cover, ), - SizedBox(height: 24 * 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), - SizedBox(height: 8 * s), - Text( - 'Kontrol ukuran teks secara spesifik per kelompok, terlepas dari skala global di atas.', - style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), - ), - SizedBox(height: 20 * s), - _scaleSlider( - s: s, - label: 'Label Shalat (Nama: SUBUH, DZUHUR…)', - value: _scaleCardLabel, - onChanged: (v) => setState(() => _scaleCardLabel = v), - ), - SizedBox(height: 16 * s), - _scaleSlider( - s: s, - label: 'Waktu & Iqamah pada kartu jadwal', - value: _scaleCardBody, - onChanged: (v) => setState(() => _scaleCardBody = v), - ), - SizedBox(height: 16 * s), - _scaleSlider( - s: s, - label: 'Teks Berjalan (Running Text)', - value: _scaleRunningText, - onChanged: (v) => setState(() => _scaleRunningText = v), - ), - - SizedBox(height: 40 * s), - - _sectionLabel('Background Layar Utama (Unsplash)', s), - SizedBox(height: 12 * s), - _buildSwitchTile( - s: s, - title: 'Gunakan Foto Unsplash API', - value: _useUnsplash, - onChanged: (val) => setState(() => _useUnsplash = val), - ), - if (_useUnsplash) ...[ - SizedBox(height: 12 * s), - _buildTextField('Kata Kunci (Contoh: mosque, architecture)', _unsplashKeywordCtrl, s), - SizedBox(height: 12 * s), - _buildTvIntStepperField( - s: s, - label: 'Rotasi Foto', - controller: _unsplashRotationCtrl, - fallback: 6, - min: 1, - max: 24, - suffix: 'jam', - ), - ], - - SizedBox(height: 56 * s), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: _saveTampilan, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.primary, - foregroundColor: SacredColors.onPrimary, - padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 24 * s), - textStyle: TextStyle(fontSize: 18 * s, fontWeight: FontWeight.bold), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), + ), + SizedBox(height: 12 * s), + Text( + _brandedBgImage!.split('/').last, + style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 12 * s), + _buildTampilanActionButton( + rowIndex: removeBrandedBgRow!, + s: s, + onActivate: () { + setState(() => _brandedBgImage = null); + _queueTampilanAutoSave( + message: 'Foto latar otomatis dihapus dan tersimpan', + ); + }, + child: OutlinedButton.icon( + onPressed: () { + setState(() => _brandedBgImage = null); + _queueTampilanAutoSave( + message: 'Foto latar otomatis dihapus dan tersimpan', + ); + }, + icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 20 * s), + label: Text( + 'HAPUS FOTO LATAR', + style: GoogleFonts.plusJakartaSans( + fontSize: 14 * s, + fontWeight: FontWeight.w700, + color: SacredColors.error, ), - icon: HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk, color: SacredColors.onPrimary), - label: const Text('SIMPAN TAMPILAN'), ), ), - ], - )), - ), - SizedBox(width: 32 * s), - - // Right: Branded Background + Slideshow - Expanded( - child: Column( - children: [ - // Branded Background Card - _adminCard(s, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionLabel('Foto Latar Utama (Branding Masjid)', s), - SizedBox(height: 16 * s), - if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(SacredRadii.md), - child: Image.file( - File(_brandedBgImage!), - height: 120 * s, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - SizedBox(height: 12 * s), - Row( - children: [ - Expanded( - child: Text( - _brandedBgImage!.split('/').last, - style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 22 * s), - onPressed: () { - setState(() => _brandedBgImage = null); - _saveTampilan(); - }, - ), - ], - ), - ] else - Text('Belum ada foto latar masjid.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)), - SizedBox(height: 16 * s), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: () async { - final res = await FilePicker.platform.pickFiles(type: FileType.image); - if (res != null && res.files.single.path != null) { - setState(() => _brandedBgImage = res.files.single.path); - _saveTampilan(); - } - }, - icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s), - label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)), - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.secondary, - foregroundColor: SacredColors.onSecondary, - padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s), - ), - ), + ), + ] else + Text('Belum ada foto latar masjid.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)), + SizedBox(height: 16 * s), + _buildTampilanActionButton( + rowIndex: pickBrandedBgRow, + s: s, + onActivate: () async { + final res = await FilePicker.platform.pickFiles(type: FileType.image); + if (res != null && res.files.single.path != null) { + setState(() => _brandedBgImage = res.files.single.path); + _queueTampilanAutoSave( + message: 'Foto latar otomatis tersimpan', + ); + } + }, + child: ElevatedButton.icon( + onPressed: () async { + final res = await FilePicker.platform.pickFiles(type: FileType.image); + if (res != null && res.files.single.path != null) { + setState(() => _brandedBgImage = res.files.single.path); + _queueTampilanAutoSave( + message: 'Foto latar otomatis tersimpan', + ); + } + }, + icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s), + label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)), + style: _tvElevatedActionStyle( + s: s, + normalBackground: SacredColors.secondary, + normalForeground: SacredColors.onPrimary, + padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s), + fontSize: 16 * s, + ), + ), + ), + ], + ), + ), + SizedBox(height: 24 * s), + _adminCard( + s, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Galeri Gambar Slideshow', s), + SizedBox(height: 16 * s), + _buildTampilanActionButton( + rowIndex: addSlideshowImageRow, + s: s, + onActivate: () async { + final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); + if (res != null) { + setState(() { + for (var path in res.paths) { + if (path != null && !_slideshowImages.contains(path)) { + _slideshowImages.add(path); + } + } + }); + _queueTampilanAutoSave( + message: 'Galeri slideshow otomatis tersimpan', + ); + } + }, + child: ElevatedButton.icon( + onPressed: () async { + final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); + if (res != null) { + setState(() { + for (var path in res.paths) { + if (path != null && !_slideshowImages.contains(path)) { + _slideshowImages.add(path); + } + } + }); + _queueTampilanAutoSave( + message: 'Galeri slideshow otomatis tersimpan', + ); + } + }, + icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onPrimary, size: 18 * s), + label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)), + style: _tvElevatedActionStyle( + s: s, + normalBackground: SacredColors.secondary, + normalForeground: SacredColors.onPrimary, + padding: EdgeInsets.symmetric(horizontal: 20 * s, vertical: 14 * s), + fontSize: 14 * s, + ), + ), + ), + SizedBox(height: 16 * s), + if (_slideshowImages.isEmpty) + Text('Belum ada gambar slideshow.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _slideshowImages.length, + separatorBuilder: (_, __) => SizedBox(height: 12 * s), + itemBuilder: (context, idx) { + final path = _slideshowImages[idx]; + return 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.3)), ), - ], - )), - SizedBox(height: 24 * s), - - // Slideshow Gallery Card - _adminCard(s, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionLabel('Galeri Gambar Slideshow', s), - _tvActionButton( + ClipRRect( + borderRadius: BorderRadius.circular(SacredRadii.sm), + child: Image.file( + File(path), + width: double.infinity, + height: 120 * s, + fit: BoxFit.cover, + ), + ), + SizedBox(height: 10 * s), + Text( + path.split('/').last, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurface), + ), + SizedBox(height: 10 * s), + _buildTampilanActionButton( + rowIndex: slideshowDeleteRows[idx], s: s, - child: ElevatedButton.icon( - onPressed: () async { - final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); - if (res != null) { - setState(() { - for (var path in res.paths) { - if (path != null && !_slideshowImages.contains(path)) { - _slideshowImages.add(path); - } - } - }); - _saveTampilan(); - } + onActivate: () { + setState(() => _slideshowImages.removeAt(idx)); + _queueTampilanAutoSave( + message: 'Galeri slideshow otomatis tersimpan', + ); + }, + child: OutlinedButton.icon( + onPressed: () { + setState(() => _slideshowImages.removeAt(idx)); + _queueTampilanAutoSave( + message: 'Galeri slideshow otomatis tersimpan', + ); }, - icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onSecondary, size: 18 * s), - label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)), - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.secondary, - foregroundColor: SacredColors.onSecondary, - padding: EdgeInsets.symmetric(horizontal: 20 * s, vertical: 14 * s), + icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 18 * s), + label: Text( + 'HAPUS FOTO', + style: GoogleFonts.plusJakartaSans( + fontSize: 13 * s, + fontWeight: FontWeight.w700, + color: SacredColors.error, + ), ), ), ), ], ), - SizedBox(height: 16 * s), - if (_slideshowImages.isEmpty) - Text('Belum ada gambar slideshow.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)) - else - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _slideshowImages.length, - itemBuilder: (context, idx) { - final path = _slideshowImages[idx]; - return ListTile( - leading: ClipRRect( - borderRadius: BorderRadius.circular(SacredRadii.sm), - child: Image.file(File(path), width: 56 * s, height: 56 * s, fit: BoxFit.cover), - ), - title: Text(path.split('/').last, maxLines: 1, overflow: TextOverflow.ellipsis, - style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurface)), - trailing: IconButton( - icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 20 * s), - onPressed: () { - setState(() => _slideshowImages.removeAt(idx)); - _saveTampilan(); - }, - ), - ); - }, - ), - ], - )), - ], - ), - ), - ], + ); + }, + ), + ], + ), ), - - SizedBox(height: 40 * s), - - // ── Row 2: Running Text Repeater ── + SizedBox(height: 24 * s), _adminCard(s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _sectionLabel('Running Text / Pengumuman', s), - Row( - children: [ - Text('Mode Animasi:', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)), - SizedBox(width: 12 * s), - _buildSegmentedControl( - s: s, - child: SegmentedButton( - segments: [ - ButtonSegment(value: 'marquee', label: Text('Marquee', style: GoogleFonts.manrope(fontSize: 16 * s))), - ButtonSegment(value: 'fade', label: Text('Fade In-Out', style: GoogleFonts.manrope(fontSize: 16 * s))), - ], - selected: {_marqueeAnimType}, - onSelectionChanged: (val) => setState(() => _marqueeAnimType = val.first), - style: ButtonStyle( - backgroundColor: WidgetStateProperty.resolveWith((states) => - states.contains(WidgetState.selected) ? SacredColors.primary : SacredColors.surfaceContainerLowest), - foregroundColor: WidgetStateProperty.resolveWith((states) => - states.contains(WidgetState.selected) ? SacredColors.onPrimary : SacredColors.onSurfaceVariant), - ), - ), - ), - ], - ), - ], + _sectionLabel('Running Text / Pengumuman', s), + SizedBox(height: 12 * s), + _buildTvChoiceField( + s: s, + rowIndex: marqueeModeRow, + label: 'Mode Animasi Running Text', + options: const ['Marquee', 'Fade In-Out'], + selectedIndex: _marqueeAnimType == 'fade' ? 1 : 0, + onChanged: (index) { + setState(() => _marqueeAnimType = index == 1 ? 'fade' : 'marquee'); + _queueTampilanAutoSave(); + }, ), SizedBox(height: 24 * s), - - // Repeater list if (_runningTexts.isEmpty) Padding( padding: EdgeInsets.symmetric(vertical: 16 * s), @@ -936,7 +1882,8 @@ class _AdminScreenState extends ConsumerState { borderRadius: BorderRadius.circular(SacredRadii.md), border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 32 * s, @@ -948,100 +1895,125 @@ class _AdminScreenState extends ConsumerState { ), child: Text('${idx + 1}', style: GoogleFonts.manrope(fontSize: 14 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)), ), - SizedBox(width: 16 * s), - Expanded( - flex: 5, - child: TextField( - controller: textCtrl, - style: GoogleFonts.plusJakartaSans(fontSize: 20 * s, color: SacredColors.onSurface), - decoration: InputDecoration( - hintText: 'Teks pengumuman...', - filled: true, - fillColor: SacredColors.surfaceContainerLow, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.sm), borderSide: BorderSide.none), - isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 14 * s), - ), - onChanged: (val) => _runningTexts[idx] = val, + SizedBox(height: 12 * s), + _TvEditableTextTile( + scale: s, + label: 'Teks Pengumuman', + focusNode: _tampilanFocusNode(runningTextTextRows[idx]), + controller: textCtrl, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow( + idx == 0 ? marqueeModeRow : runningTextDeleteRows[idx - 1], ), + onMoveDown: () => _focusTampilanRow(runningTextDurationRows[idx]), + onChanged: (val) { + _runningTexts[idx] = val; + }, + onEditComplete: () { + _queueTampilanAutoSave( + message: 'Teks berjalan otomatis tersimpan', + ); + }, ), - SizedBox(width: 12 * s), + SizedBox(height: 12 * s), SizedBox( - width: 100 * s, - child: TextField( + width: 180 * s, + child: _TvEditableTextTile( + scale: s, + label: 'Durasi (detik)', + focusNode: _tampilanFocusNode(runningTextDurationRows[idx]), controller: durCtrl, keyboardType: TextInputType.number, - style: GoogleFonts.plusJakartaSans(fontSize: 20 * s, color: SacredColors.onSurface), - decoration: InputDecoration( - hintText: 'Detik', - filled: true, - fillColor: SacredColors.surfaceContainerLow, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.sm), borderSide: BorderSide.none), - isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 14 * s), - suffixText: 'dtk', - ), - onChanged: (val) => _runningTextDurations[idx] = int.tryParse(val) ?? 12, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(runningTextTextRows[idx]), + onMoveDown: () => _focusTampilanRow(runningTextDeleteRows[idx]), + onChanged: (val) { + _runningTextDurations[idx] = int.tryParse(val) ?? 12; + }, + onEditComplete: () { + _queueTampilanAutoSave( + message: 'Teks berjalan otomatis tersimpan', + ); + }, ), ), - SizedBox(width: 8 * s), - IconButton( - icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 22 * s), - onPressed: () { + SizedBox(height: 10 * s), + _buildTampilanActionButton( + rowIndex: runningTextDeleteRows[idx], + s: s, + onActivate: () { setState(() { _runningTexts.removeAt(idx); _runningTextDurations.removeAt(idx); }); + _queueTampilanAutoSave( + message: 'Teks berjalan otomatis tersimpan', + ); }, + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _runningTexts.removeAt(idx); + _runningTextDurations.removeAt(idx); + }); + _queueTampilanAutoSave( + message: 'Teks berjalan otomatis tersimpan', + ); + }, + icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 18 * s), + label: Text( + 'HAPUS BARIS', + style: GoogleFonts.plusJakartaSans( + fontSize: 13 * s, + fontWeight: FontWeight.w700, + color: SacredColors.error, + ), + ), + ), ), ], ), ); }, ), - SizedBox(height: 20 * s), - Row( - children: [ - _tvActionButton( - s: s, - child: OutlinedButton.icon( - onPressed: () { - setState(() { - _runningTexts.add(''); - _runningTextDurations.add(12); - }); - }, - icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.primary, size: 20 * s), - label: Text('TAMBAH BARIS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, color: SacredColors.primary)), - style: OutlinedButton.styleFrom( - side: BorderSide(color: SacredColors.primary.withValues(alpha: 0.5)), - padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s), - ), - ), + _buildTampilanActionButton( + rowIndex: addRunningTextRow, + s: s, + onActivate: () { + setState(() { + _runningTexts.add(''); + _runningTextDurations.add(12); + }); + _queueTampilanAutoSave( + message: 'Baris teks otomatis ditambahkan', + ); + }, + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _runningTexts.add(''); + _runningTextDurations.add(12); + }); + _queueTampilanAutoSave( + message: 'Baris teks otomatis ditambahkan', + ); + }, + icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.primary, size: 20 * s), + label: Text('TAMBAH BARIS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, color: SacredColors.primary)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: SacredColors.primary.withValues(alpha: 0.5)), + padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s), ), - SizedBox(width: 16 * s), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: _saveTampilan, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.primary, - foregroundColor: SacredColors.onPrimary, - padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 16 * s), - ), - icon: HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk, color: SacredColors.onPrimary, size: 18 * s), - label: Text('SIMPAN TEKS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.bold)), - ), - ), - ], + ), ), ], )), - SizedBox(height: 40 * s), ], ), + ), + ), ); } @@ -1085,91 +2057,354 @@ class _AdminScreenState extends ConsumerState { ); } - Widget _tvActionButton({ - required Widget child, + ButtonStyle _tvElevatedActionStyle({ required double s, + required Color normalBackground, + required Color normalForeground, + EdgeInsetsGeometry? padding, double radius = SacredRadii.lg, + double fontSize = 16, + FontWeight fontWeight = FontWeight.bold, }) { - return _tvFocusable( - child: child, - s: s, - radius: radius, - scrollAware: true, - ); - } - - Widget _buildReadonlyField(TextEditingController controller, double s) { - return _tvFocusable( - s: s, - child: TextField( - controller: controller, - readOnly: true, - style: GoogleFonts.plusJakartaSans( - fontSize: 24 * s, - color: SacredColors.onSurface, - ), - decoration: InputDecoration( - filled: true, - fillColor: SacredColors.surfaceContainerLowest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(SacredRadii.md), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(SacredRadii.md), - borderSide: const BorderSide(color: SacredColors.primary, width: 2), - ), - ), + return ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return normalBackground.withValues(alpha: 0.45); + } + if (states.contains(WidgetState.focused)) { + return SacredColors.primary; + } + return normalBackground; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return normalForeground.withValues(alpha: 0.6); + } + if (states.contains(WidgetState.focused)) { + return SacredColors.onPrimary; + } + return normalForeground; + }), + textStyle: WidgetStatePropertyAll( + TextStyle(fontSize: fontSize, fontWeight: fontWeight), + ), + padding: WidgetStatePropertyAll( + padding ?? EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)), ), ); } - Widget _buildSwitchTile({ - required double s, - required String title, - required bool value, - required ValueChanged onChanged, + Widget _buildReadonlyField( + TextEditingController controller, + double s, { + bool focusable = true, + FocusNode? focusNode, + VoidCallback? onMoveLeft, + VoidCallback? onMoveUp, + VoidCallback? onMoveDown, }) { - return _tvFocusable( - s: s, - radius: SacredRadii.md, - child: Container( - decoration: BoxDecoration( - color: SacredColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(SacredRadii.md), + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Lokasi Saat Ini', + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w600, + color: SacredColors.onSurfaceVariant, + ), ), - child: SwitchListTile( - title: Text( - title, + SizedBox(height: 12 * s), + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 18 * s), + decoration: BoxDecoration( + color: SacredColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(SacredRadii.md), + border: Border.all( + color: SacredColors.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Text( + controller.text, style: GoogleFonts.plusJakartaSans( - fontSize: 18 * s, + fontSize: 24 * s, color: SacredColors.onSurface, ), ), - value: value, - onChanged: onChanged, - activeThumbColor: SacredColors.primary, - contentPadding: EdgeInsets.symmetric(horizontal: 12 * s), + ), + ], + ); + + if (!focusable) { + return content; + } + + return _scrollAware( + controller: _scrollControllerForTab(_selectedTab), + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowLeft) { + onMoveLeft?.call(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowUp) { + onMoveUp?.call(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowDown) { + onMoveDown?.call(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: _tvFocusable( + s: s, + scrollAware: false, + child: content, ), ), ); } - Widget _buildSegmentedControl({ + Widget _buildJadwalActionButton({ + required int rowIndex, required double s, - required Widget child, - double radius = SacredRadii.md, + required VoidCallback onActivate, + Widget? child, + Widget Function(bool isFocused)? builder, }) { - return _tvFocusable( - s: s, - radius: radius, - child: Container( - padding: EdgeInsets.all(6 * s), - decoration: BoxDecoration( - color: SacredColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(radius), + assert(child != null || builder != null); + final focusNode = _jadwalFocusNodes[rowIndex]; + + return _scrollAware( + controller: _jadwalScrollController, + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) => + _handleJadwalActionKey(rowIndex, event, onActivate: onActivate), + child: ListenableBuilder( + listenable: focusNode, + builder: (context, _) { + final isFocused = focusNode.hasFocus; + return AnimatedScale( + scale: isFocused ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(isFocused ? 5 * s : 0), + decoration: BoxDecoration( + color: isFocused + ? SacredColors.surfaceContainerLow.withValues(alpha: 0.96) + : Colors.transparent, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: Border.all( + color: isFocused + ? SacredColors.primary.withValues(alpha: 0.95) + : Colors.transparent, + width: isFocused ? 3 : 0, + ), + boxShadow: isFocused + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: ExcludeFocus( + child: builder?.call(isFocused) ?? child!, + ), + ), + ); + }, ), - child: child, + ), + ); + } + + Widget _buildTampilanActionButton({ + required int rowIndex, + required double s, + required VoidCallback onActivate, + Widget? child, + Widget Function(bool isFocused)? builder, + }) { + assert(child != null || builder != null); + final focusNode = _tampilanFocusNode(rowIndex); + + return _scrollAware( + controller: _tampilanScrollController, + child: Focus( + focusNode: focusNode, + onKeyEvent: (node, event) => _handleTampilanActionKey( + rowIndex, + event, + onActivate: onActivate, + ), + child: ListenableBuilder( + listenable: focusNode, + builder: (context, _) { + final isFocused = focusNode.hasFocus; + return AnimatedScale( + scale: isFocused ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(isFocused ? 5 * s : 0), + decoration: BoxDecoration( + color: isFocused + ? SacredColors.surfaceContainerLow.withValues(alpha: 0.96) + : Colors.transparent, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: Border.all( + color: isFocused + ? SacredColors.primary.withValues(alpha: 0.95) + : Colors.transparent, + width: isFocused ? 3 : 0, + ), + boxShadow: isFocused + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: builder != null ? builder(isFocused) : child!, + ), + ); + }, + ), + ), + ); + } + + Widget _buildTvChoiceField({ + required double s, + required int rowIndex, + required String label, + required List options, + required int selectedIndex, + required ValueChanged onChanged, + }) { + final maxIndex = options.length - 1; + return _buildTvAdjustTile( + s: s, + focusNode: _tampilanFocusNode(rowIndex), + label: label, + valueLabel: options[selectedIndex], + progress: maxIndex <= 0 ? 1 : selectedIndex / maxIndex, + helperText: + 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk memilih.', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: rowIndex > 0 ? () => _focusTampilanRow(rowIndex - 1) : null, + onMoveDown: rowIndex + 1 < _tampilanRowCount() + ? () => _focusTampilanRow(rowIndex + 1) + : null, + onIncrement: () { + if (selectedIndex < maxIndex) { + onChanged(selectedIndex + 1); + } + }, + onDecrement: () { + if (selectedIndex > 0) { + onChanged(selectedIndex - 1); + } + }, + ); + } + + Widget _buildTvBoolField({ + required double s, + required int rowIndex, + required String label, + required bool value, + required ValueChanged onChanged, + String trueLabel = 'Aktif', + String falseLabel = 'Nonaktif', + }) { + return _buildTvAdjustTile( + s: s, + focusNode: _tampilanFocusNode(rowIndex), + label: label, + valueLabel: value ? trueLabel : falseLabel, + progress: value ? 1 : 0, + helperText: + 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk mengganti status.', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: rowIndex > 0 ? () => _focusTampilanRow(rowIndex - 1) : null, + onMoveDown: rowIndex + 1 < _tampilanRowCount() + ? () => _focusTampilanRow(rowIndex + 1) + : null, + onIncrement: () { + if (!value) onChanged(true); + }, + onDecrement: () { + if (value) onChanged(false); + }, + ); + } + + Widget _buildTvPrimaryActionSurface({ + required double s, + required Widget icon, + required String label, + required bool isFocused, + }) { + final backgroundColor = + isFocused ? SacredColors.primary : SacredColors.secondary; + final foregroundColor = SacredColors.onPrimary; + + return AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 28 * s), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(SacredRadii.lg), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData(color: foregroundColor, size: 24 * s), + child: DefaultTextStyle( + style: GoogleFonts.plusJakartaSans( + fontSize: 20 * s, + fontWeight: FontWeight.bold, + color: foregroundColor, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + SizedBox(width: 16 * s), + Text(label), + ], + ), + ), + ), + ], ), ); } @@ -1188,88 +2423,151 @@ class _AdminScreenState extends ConsumerState { Widget _buildIdentityTab(double s) { - return SingleChildScrollView( - controller: _identityScrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Identitas & Lokasi Masjid', - style: GoogleFonts.plusJakartaSans( - fontSize: 48 * s, - fontWeight: FontWeight.w700, - color: SacredColors.primary, - ), - ), - SizedBox(height: 48 * s), - Container( - width: 800 * s, - padding: EdgeInsets.all(40 * 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)), - ), + final nameRow = 0; + final addressRow = 1; + final searchRow = 2; + + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) => _handleSimpleTabKey(event), + child: SingleChildScrollView( + controller: _identityScrollController, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTextField('Nama Masjid', _masjidNameCtrl, s), - SizedBox(height: 32 * s), - _buildTextField('Alamat Lengkap', _masjidAddressCtrl, s, maxLines: 2), - SizedBox(height: 32 * s), - - // City API Config - Text( - 'Lokasi Jadwal Shalat (MyQuran API)', - style: GoogleFonts.manrope( - fontSize: 16 * s, - fontWeight: FontWeight.w600, - color: SacredColors.onSurfaceVariant, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Identitas & Lokasi Masjid', + style: GoogleFonts.plusJakartaSans( + fontSize: 48 * s, + fontWeight: FontWeight.w700, + color: SacredColors.primary, + ), + ), + SizedBox(height: 48 * s), + _adminCard( + s, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField( + 'Nama Masjid', + _masjidNameCtrl, + s, + focusNode: _identityFocusNodes[nameRow], + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveDown: () => _focusIdentityRow(addressRow), ), - ), - SizedBox(height: 12 * s), - Row( - children: [ - Expanded( - child: _buildReadonlyField(_cityCtrl, s), + SizedBox(height: 32 * s), + _buildTextField( + 'Alamat Lengkap', + _masjidAddressCtrl, + s, + maxLines: 2, + focusNode: _identityFocusNodes[addressRow], + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusIdentityRow(nameRow), + onMoveDown: () => _focusIdentityRow(searchRow), + ), + SizedBox(height: 32 * s), + Text( + 'Lokasi Jadwal Shalat (MyQuran API)', + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w600, + color: SacredColors.onSurfaceVariant, ), - SizedBox(width: 16 * s), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: () => _showCitySearchDialog(s), - icon: HugeIcon(icon: HugeIcons.strokeRoundedSearch01, color: SacredColors.onPrimary), - label: Text('CARI KOTA', style: TextStyle(fontSize: 16 * s)), - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.secondary, - foregroundColor: SacredColors.onPrimary, - padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s), - ), + ), + SizedBox(height: 12 * s), + _buildReadonlyField( + _cityCtrl, + s, + focusable: false, + ), + SizedBox(height: 16 * s), + _scrollAware( + controller: _identityScrollController, + child: Focus( + focusNode: _identityFocusNodes[searchRow], + onKeyEvent: (node, event) => _handleIdentityActionKey( + searchRow, + event, + onActivate: () => _showCitySearchDialog(s), + ), + child: ListenableBuilder( + listenable: _identityFocusNodes[searchRow], + builder: (context, _) { + final isFocused = + _identityFocusNodes[searchRow].hasFocus; + return AnimatedScale( + scale: isFocused ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(isFocused ? 5 * s : 0), + decoration: BoxDecoration( + color: isFocused + ? SacredColors.surfaceContainerLow + .withValues(alpha: 0.96) + : Colors.transparent, + borderRadius: + BorderRadius.circular(SacredRadii.lg), + border: Border.all( + color: isFocused + ? SacredColors.primary + .withValues(alpha: 0.95) + : Colors.transparent, + width: isFocused ? 3 : 0, + ), + boxShadow: isFocused + ? [ + BoxShadow( + color: SacredColors.primary + .withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: ElevatedButton.icon( + onPressed: () => _showCitySearchDialog(s), + icon: HugeIcon( + icon: HugeIcons.strokeRoundedSearch01, + color: isFocused + ? SacredColors.onPrimary + : SacredColors.onPrimary, + ), + label: Text( + 'CARI KOTA', + style: TextStyle(fontSize: 16 * s), + ), + style: _tvElevatedActionStyle( + s: s, + normalBackground: SacredColors.secondary, + normalForeground: SacredColors.onPrimary, + padding: EdgeInsets.symmetric( + horizontal: 24 * s, + vertical: 24 * s, + ), + fontSize: 16 * s, + ), + ), + ), + ); + }, ), ), - ], - ), - - SizedBox(height: 64 * s), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: _saveIdentity, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.primary, - foregroundColor: SacredColors.onPrimary, - padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 24 * s), - textStyle: TextStyle(fontSize: 20 * s, fontWeight: FontWeight.bold), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), - ), - icon: HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk, color: SacredColors.onPrimary), - label: const Text('SIMPAN PERUBAHAN TULISAN'), ), - ), - ], + ], + ), ), + ], + ), ), - ], ), ); } @@ -1308,7 +2606,6 @@ class _AdminScreenState extends ConsumerState { border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.4)), ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( @@ -1334,27 +2631,41 @@ class _AdminScreenState extends ConsumerState { ], ), ), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: _isSyncing ? null : _syncData, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.secondary, - foregroundColor: SacredColors.onSecondary, - padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 32 * s), - textStyle: TextStyle(fontSize: 20 * s, fontWeight: FontWeight.bold), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), - ), - icon: _isSyncing - ? SizedBox(width: 24*s, height: 24*s, child: const CircularProgressIndicator(color: SacredColors.onSecondary, strokeWidth: 3)) - : HugeIcon(icon: HugeIcons.strokeRoundedCloudDownload, color: SacredColors.onSecondary), - label: Text(_isSyncing ? 'MENYINKRONKAN...' : 'SINKRONKAN DATA BULAN INI'), - ), - ) ], ), ), + SizedBox(height: 20 * s), + _buildJadwalActionButton( + rowIndex: 0, + s: s, + onActivate: _isSyncing ? () {} : _syncData, + builder: (isFocused) => _buildTvPrimaryActionSurface( + s: s, + isFocused: isFocused, + icon: _isSyncing + ? SizedBox( + width: 24 * s, + height: 24 * s, + child: CircularProgressIndicator( + color: isFocused + ? SacredColors.onPrimary + : SacredColors.onSecondary, + strokeWidth: 3, + ), + ) + : HugeIcon( + icon: HugeIcons.strokeRoundedCloudDownload, + color: isFocused + ? SacredColors.onPrimary + : SacredColors.onSecondary, + ), + label: _isSyncing + ? 'MENYINKRONKAN...' + : 'SINKRONKAN DATA BULAN INI', + ), + ), + SizedBox(height: 64 * s), _adminCard( @@ -1429,40 +2740,37 @@ class _AdminScreenState extends ConsumerState { ), ), SizedBox(height: 20 * s), - _buildHijriOffsetControl(s), + _buildHijriOffsetControl( + s, + focusNode: _jadwalFocusNodes[1], + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(0), + onMoveDown: () => _focusJadwalRow(2), + ), SizedBox(height: 16 * s), - Row( - children: [ - _tvActionButton( - s: s, - child: OutlinedButton.icon( - onPressed: () { - setState(() { - _hijriOffsetDays = 0; - }); - }, - icon: const Icon(Icons.refresh), - label: const Text('RESET OFFSET'), - ), - ), - SizedBox(width: 16 * s), - _tvActionButton( - s: s, - child: ElevatedButton.icon( - onPressed: _saveHijriSettings, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.primary, - foregroundColor: SacredColors.onPrimary, - padding: EdgeInsets.symmetric( - horizontal: 28 * s, - vertical: 18 * s, - ), - ), - icon: const Icon(Icons.save_rounded), - label: const Text('SIMPAN OFFSET HIJRIAH'), - ), - ), - ], + _buildJadwalActionButton( + rowIndex: 2, + s: s, + onActivate: () { + setState(() { + _hijriOffsetDays = 0; + }); + _queueJadwalAutoSave( + message: 'Offset Hijriah direset dan otomatis tersimpan', + ); + }, + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _hijriOffsetDays = 0; + }); + _queueJadwalAutoSave( + message: 'Offset Hijriah direset dan otomatis tersimpan', + ); + }, + icon: const Icon(Icons.refresh), + label: const Text('RESET OFFSET'), + ), ), ], ), @@ -1481,44 +2789,49 @@ class _AdminScreenState extends ConsumerState { style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 32 * s), - Row( - children: [ - Expanded( - child: _buildTvIntStepperField( - s: s, - label: 'Pra-Adzan', - controller: _preAdzanLeadCtrl, - fallback: 10, - min: 0, - max: 60, - suffix: 'menit', - ), - ), - SizedBox(width: 16 * 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: _buildTvIntStepperField( - s: s, - label: 'Blank Screen Jumat', - controller: _blankJumatCtrl, - fallback: 45, - min: 0, - max: 180, - suffix: 'menit', - ), - ), - ], + _buildTvIntStepperField( + s: s, + label: 'Pra-Adzan', + focusNode: _jadwalFocusNodes[3], + controller: _preAdzanLeadCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(2), + onMoveDown: () => _focusJadwalRow(4), + onValueChanged: _queueJadwalAutoSave, + ), + SizedBox(height: 16 * s), + _buildTvIntStepperField( + s: s, + label: 'Blank Screen Normal', + focusNode: _jadwalFocusNodes[4], + controller: _blankNormalCtrl, + fallback: 15, + min: 0, + max: 120, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(3), + onMoveDown: () => _focusJadwalRow(5), + onValueChanged: _queueJadwalAutoSave, + ), + SizedBox(height: 16 * s), + _buildTvIntStepperField( + s: s, + label: 'Blank Screen Jumat', + focusNode: _jadwalFocusNodes[5], + controller: _blankJumatCtrl, + fallback: 45, + min: 0, + max: 180, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(4), + onMoveDown: () => _focusJadwalRow(6), + onValueChanged: _queueJadwalAutoSave, ), SizedBox(height: 28 * s), Text( @@ -1535,90 +2848,78 @@ class _AdminScreenState extends ConsumerState { 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', - ), - ), - ], + _buildTvIntStepperField( + s: s, + label: 'Iqamah Subuh', + focusNode: _jadwalFocusNodes[6], + controller: _iqomahSubuhCtrl, + fallback: 15, + min: 0, + max: 60, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(5), + onMoveDown: () => _focusJadwalRow(7), + onValueChanged: _queueJadwalAutoSave, ), SizedBox(height: 16 * s), - Row( - children: [ - Expanded( - child: _buildTvIntStepperField( - s: s, - label: 'Iqamah Maghrib', - controller: _iqomahMaghribCtrl, - fallback: 7, - min: 0, - max: 60, - suffix: 'menit', - ), - ), - SizedBox(width: 16 * 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), - _tvActionButton( + _buildTvIntStepperField( s: s, - child: ElevatedButton.icon( - onPressed: _saveJadwalTimingSettings, - style: ElevatedButton.styleFrom( - backgroundColor: SacredColors.secondary, - foregroundColor: Colors.black, - padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 20 * s), - textStyle: TextStyle(fontSize: 18 * s, fontWeight: FontWeight.bold), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)), - ), - icon: const Icon(Icons.timer), - label: const Text('SIMPAN PENGATURAN JADWAL'), - ), + label: 'Iqamah Dzuhur', + focusNode: _jadwalFocusNodes[7], + controller: _iqomahDzuhurCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(6), + onMoveDown: () => _focusJadwalRow(8), + onValueChanged: _queueJadwalAutoSave, + ), + SizedBox(height: 16 * s), + _buildTvIntStepperField( + s: s, + label: 'Iqamah Ashar', + focusNode: _jadwalFocusNodes[8], + controller: _iqomahAsharCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(7), + onMoveDown: () => _focusJadwalRow(9), + onValueChanged: _queueJadwalAutoSave, + ), + SizedBox(height: 16 * s), + _buildTvIntStepperField( + s: s, + label: 'Iqamah Maghrib', + focusNode: _jadwalFocusNodes[9], + controller: _iqomahMaghribCtrl, + fallback: 7, + min: 0, + max: 60, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(8), + onMoveDown: () => _focusJadwalRow(10), + onValueChanged: _queueJadwalAutoSave, + ), + SizedBox(height: 16 * s), + _buildTvIntStepperField( + s: s, + label: 'Iqamah Isya', + focusNode: _jadwalFocusNodes[10], + controller: _iqomahIsyaCtrl, + fallback: 10, + min: 0, + max: 60, + suffix: 'menit', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusJadwalRow(9), + onValueChanged: _queueJadwalAutoSave, ), ], )), @@ -1682,172 +2983,43 @@ class _AdminScreenState extends ConsumerState { ); } - Widget _buildHijriOffsetControl(double s) { + Widget _buildHijriOffsetControl( + double s, { + FocusNode? focusNode, + VoidCallback? onMoveLeft, + VoidCallback? onMoveUp, + VoidCallback? onMoveDown, + }) { const minOffset = -3; const maxOffset = 3; - final valueLabel = - '${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari'; - final progress = (_hijriOffsetDays - minOffset) / (maxOffset - minOffset); - - return 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( - 'Offset Hari Hijriah', - 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: () { - setState(() { - _hijriOffsetDays = - (_hijriOffsetDays - 1).clamp(minOffset, maxOffset); - }); - }, - ), - SizedBox(width: 10 * s), - Expanded( - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Container( - height: 6 * s, - decoration: BoxDecoration( - color: SacredColors.outlineVariant.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(3 * s), - ), - ), - FractionallySizedBox( - widthFactor: progress.clamp(0.0, 1.0), - child: Container( - height: 6 * s, - decoration: BoxDecoration( - color: SacredColors.primary, - borderRadius: BorderRadius.circular(3 * s), - ), - ), - ), - ], - ), - ), - SizedBox(width: 10 * s), - _tvStepBtn( - s: s, - label: '+', - onPressed: () { - setState(() { - _hijriOffsetDays = - (_hijriOffsetDays + 1).clamp(minOffset, maxOffset); - }); - }, - ), - ], - ), - SizedBox(height: 12 * s), - Row( - children: [ - Text( - 'Preset: ', - style: GoogleFonts.manrope( - fontSize: 12 * s, - color: SacredColors.onSurfaceVariant, - ), - ), - ...[-2, -1, 0, 1, 2].map((offset) { - final isActive = _hijriOffsetDays == offset; - final label = '${offset >= 0 ? '+' : ''}$offset'; - return Padding( - padding: EdgeInsets.only(right: 8 * s), - child: InkWell( - focusColor: SacredColors.primary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(SacredRadii.sm), - onTap: () => setState(() => _hijriOffsetDays = offset), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 12 * s, - vertical: 6 * s, - ), - decoration: BoxDecoration( - color: isActive - ? SacredColors.primary - : SacredColors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(SacredRadii.sm), - border: isActive - ? null - : Border.all( - color: SacredColors.outlineVariant.withValues( - alpha: 0.3, - ), - ), - ), - child: Text( - label, - style: GoogleFonts.manrope( - fontSize: 13 * s, - fontWeight: FontWeight.w600, - color: isActive - ? SacredColors.onPrimary - : SacredColors.onSurfaceVariant, - ), - ), - ), - ), - ); - }), - ], - ), - SizedBox(height: 6 * s), - Text( - 'TV Remote: fokus ke tombol − atau + lalu tekan OK untuk ubah satu hari.', - style: GoogleFonts.manrope( - fontSize: 11 * s, - color: SacredColors.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], - ), + return _buildTvAdjustTile( + s: s, + focusNode: focusNode, + label: 'Offset Hari Hijriah', + valueLabel: '${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari', + progress: (_hijriOffsetDays - minOffset) / (maxOffset - minOffset), + helperText: 'Tekan OK untuk masuk mode ubah. Saat aktif, gunakan ← → untuk geser 1 hari.', + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onIncrement: () { + setState(() { + _hijriOffsetDays = (_hijriOffsetDays + 1).clamp(minOffset, maxOffset); + }); + _queueJadwalAutoSave( + message: + 'Offset Hijriah ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari tersimpan', + ); + }, + onDecrement: () { + setState(() { + _hijriOffsetDays = (_hijriOffsetDays - 1).clamp(minOffset, maxOffset); + }); + _queueJadwalAutoSave( + message: + 'Offset Hijriah ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari tersimpan', + ); + }, ); } @@ -1955,185 +3127,111 @@ class _AdminScreenState extends ConsumerState { Widget _buildTvIntStepperField({ required double s, required String label, + FocusNode? focusNode, required TextEditingController controller, required int fallback, required int min, required int max, String suffix = '', - int fastStep = 5, + VoidCallback? onMoveLeft, + VoidCallback? onMoveUp, + VoidCallback? onMoveDown, + VoidCallback? onValueChanged, }) { final value = _parseCtrlInt(controller, fallback); final valueLabel = suffix.isEmpty ? '$value' : '$value $suffix'; return _scrollAware( controller: _scrollControllerForTab(_selectedTab), - child: _TvFocusFrame( - scale: s, - borderRadius: BorderRadius.circular(SacredRadii.md), - 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, - ), - ), - ], - ), - ], - ), - ), + child: _buildTvAdjustTile( + s: s, + focusNode: focusNode, + label: label, + valueLabel: valueLabel, + progress: ((value - min) / (max - min)).clamp(0.0, 1.0), + helperText: 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk menyesuaikan nilai.', + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onIncrement: () { + _bumpCtrlInt( + controller, + delta: 1, + min: min, + max: max, + fallback: fallback, + ); + onValueChanged?.call(); + }, + onDecrement: () { + _bumpCtrlInt( + controller, + delta: -1, + min: min, + max: max, + fallback: fallback, + ); + onValueChanged?.call(); + }, ), ); } - Widget _buildTextField(String label, TextEditingController ctrl, double s, {int maxLines = 1}) { + Widget _buildTvAdjustTile({ + required double s, + FocusNode? focusNode, + required String label, + required String valueLabel, + required double progress, + required String helperText, + VoidCallback? onMoveLeft, + VoidCallback? onMoveUp, + VoidCallback? onMoveDown, + required VoidCallback onIncrement, + required VoidCallback onDecrement, + }) { + return _TvAdjustTile( + scale: s, + focusNode: focusNode, + label: label, + valueLabel: valueLabel, + progress: progress, + helperText: helperText, + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onIncrement: onIncrement, + onDecrement: onDecrement, + ); + } + + Widget _buildTextField( + String label, + TextEditingController ctrl, + double s, { + int maxLines = 1, + FocusNode? focusNode, + TextInputType? keyboardType, + ValueChanged? onChanged, + VoidCallback? onEditComplete, + VoidCallback? onMoveLeft, + VoidCallback? onMoveUp, + VoidCallback? onMoveDown, + }) { return _scrollAware( controller: _scrollControllerForTab(_selectedTab), - child: _TvFocusFrame( + child: _TvEditableTextTile( scale: s, - borderRadius: BorderRadius.circular(SacredRadii.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: GoogleFonts.manrope( - fontSize: 16 * s, - fontWeight: FontWeight.w600, - color: SacredColors.onSurfaceVariant, - ), - ), - SizedBox(height: 12 * s), - TextField( - controller: ctrl, - maxLines: maxLines, - style: GoogleFonts.plusJakartaSans( - fontSize: 24 * s, - color: SacredColors.onSurface, - ), - decoration: InputDecoration( - filled: true, - fillColor: SacredColors.surfaceContainerLowest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(SacredRadii.md), - borderSide: BorderSide(color: SacredColors.outlineVariant.withValues(alpha: 0.5)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(SacredRadii.md), - borderSide: const BorderSide(color: SacredColors.primary, width: 2), - ), - ), - ), - ], - ), + label: label, + controller: ctrl, + focusNode: focusNode, + maxLines: maxLines, + keyboardType: keyboardType, + onChanged: onChanged, + onEditComplete: onEditComplete, + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, ), ); } @@ -2186,158 +3284,41 @@ class _AdminScreenState extends ConsumerState { required String label, required double value, required ValueChanged onChanged, + FocusNode? focusNode, + VoidCallback? onMoveLeft, + VoidCallback? onMoveUp, + VoidCallback? onMoveDown, }) { final pct = (value * 100).round(); const step = 0.05; - const presets = [0.75, 1.0, 1.25, 1.5]; - - return _tvFocusable( + return _buildTvAdjustTile( s: s, - 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('$pct%', style: GoogleFonts.manrope( - fontSize: 16 * s, fontWeight: FontWeight.w800, color: SacredColors.primary)), - ), - ], - ), - SizedBox(height: 14 * s), - // TV-remote control row - Row( - children: [ - _tvStepBtn(s: s, label: '−−', onPressed: () => onChanged((value - step * 4).clamp(0.5, 2.0))), - SizedBox(width: 6 * s), - _tvStepBtn(s: s, label: '−', onPressed: () => onChanged((value - step).clamp(0.5, 2.0))), - SizedBox(width: 10 * s), - Expanded( - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Container( - height: 6 * s, - decoration: BoxDecoration( - color: SacredColors.outlineVariant.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(3 * s), - ), - ), - FractionallySizedBox( - widthFactor: ((value - 0.5) / 1.5).clamp(0.0, 1.0), - child: Container( - height: 6 * s, - decoration: BoxDecoration( - color: SacredColors.primary, - borderRadius: BorderRadius.circular(3 * s), - ), - ), - ), - ], - ), - ), - SizedBox(width: 10 * s), - _tvStepBtn(s: s, label: '+', onPressed: () => onChanged((value + step).clamp(0.5, 2.0))), - SizedBox(width: 6 * s), - _tvStepBtn(s: s, label: '++', onPressed: () => onChanged((value + step * 4).clamp(0.5, 2.0))), - ], - ), - SizedBox(height: 12 * s), - // Quick preset chips - Row( - children: [ - Text('Cepat: ', style: GoogleFonts.manrope(fontSize: 12 * s, color: SacredColors.onSurfaceVariant)), - ...presets.map((p) { - final isActive = (value - p).abs() < 0.02; - return Padding( - padding: EdgeInsets.only(right: 8 * s), - child: InkWell( - focusColor: SacredColors.primary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(SacredRadii.sm), - onTap: () => onChanged(p), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12 * s, vertical: 6 * s), - decoration: BoxDecoration( - color: isActive ? SacredColors.primary : SacredColors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(SacredRadii.sm), - border: isActive ? null : Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)), - ), - child: Text('${(p * 100).round()}%', style: GoogleFonts.manrope( - fontSize: 13 * s, fontWeight: FontWeight.w600, - color: isActive ? SacredColors.onPrimary : SacredColors.onSurfaceVariant)), - ), - ), - ); - }), - ], - ), - SizedBox(height: 6 * s), - Text( - 'TV Remote: gunakan ↑↓ untuk pindah fokus, tekan OK pada −/+ untuk mengubah nilai.', - style: GoogleFonts.manrope(fontSize: 11 * s, color: SacredColors.onSurfaceVariant.withValues(alpha: 0.7)), - ), - ], - ), - ), - ); - } - - Widget _tvStepBtn({required double s, required String label, required VoidCallback onPressed}) { - return _tvFocusable( - s: s, - radius: SacredRadii.sm, - scrollAware: false, - child: Material( - color: Colors.transparent, - child: InkWell( - focusColor: SacredColors.primary.withValues(alpha: 0.35), - hoverColor: SacredColors.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(SacredRadii.sm), - onTap: onPressed, - child: Container( - width: 42 * s, - height: 38 * s, - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.4)), - borderRadius: BorderRadius.circular(SacredRadii.sm), - color: SacredColors.surfaceContainerHighest, - ), - child: Text( - label, - style: GoogleFonts.manrope( - fontSize: 15 * s, - fontWeight: FontWeight.w700, - color: SacredColors.onSurface, - ), - ), - ), - ), - ), + focusNode: focusNode, + label: label, + valueLabel: '$pct%', + progress: ((value - 0.5) / 1.5).clamp(0.0, 1.0), + helperText: 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk mengubah skala 5%.', + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onIncrement: () => onChanged((value + step).clamp(0.5, 2.0)), + onDecrement: () => onChanged((value - step).clamp(0.5, 2.0)), ); } Widget _buildSimulasiTab(double s) { - return SingleChildScrollView( - controller: _simulasiScrollController, - child: Column( + final simulationOffset = ref.watch(mockTimeOffsetProvider); + final isSimulating = simulationOffset != Duration.zero; + final simulatedMinutes = simulationOffset.inMinutes; + + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) => _handleSimpleTabKey(event), + child: SingleChildScrollView( + controller: _simulasiScrollController, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -2354,96 +3335,247 @@ class _AdminScreenState extends ConsumerState { style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 48 * s), - - Wrap( - spacing: 24 * s, - runSpacing: 24 * s, - children: [ - _simulasiCard( - s: s, - title: 'Reset Waktu Asli', - icon: HugeIcons.strokeRoundedHome01, - desc: 'Kembali ke waktu saat ini secara sinkron dengan jam sistem.', - onTap: () => _simulateTimeOffset(Duration.zero), + Container( + width: double.infinity, + padding: EdgeInsets.all(20 * s), + decoration: BoxDecoration( + color: isSimulating + ? SacredColors.error.withValues(alpha: 0.12) + : SacredColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: Border.all( + color: isSimulating + ? SacredColors.error.withValues(alpha: 0.45) + : SacredColors.outlineVariant.withValues(alpha: 0.3), ), - _simulasiCard( - s: s, - title: 'Menuju Adzan', - icon: HugeIcons.strokeRoundedClock01, - desc: 'Melompat ke 2 menit sebelum Adzan Dzuhur hari ini.', - onTap: () => _simulateEvent('pre_adzan'), - ), - _simulasiCard( - s: s, - title: 'Selama Adzan', - icon: HugeIcons.strokeRoundedMegaphone01, - desc: 'Melompat ke tepat waktu Adzan Dzuhur berkumandang.', - onTap: () => _simulateEvent('adzan'), - ), - _simulasiCard( - s: s, - title: 'Menuju Iqomah', - icon: HugeIcons.strokeRoundedTimer02, - desc: 'Melompat ke saat waktu iqomah sedang menghitung mundur (1 menit setelah Adzan).', - onTap: () => _simulateEvent('iqomah'), - ), - _simulasiCard( - s: s, - title: 'Persiapan Jumat', - icon: HugeIcons.strokeRoundedCalendar03, - desc: 'Menyimulasikan layar khusus persiapan Jumat (30 menit sebelum Adzan Dzuhur).', - onTap: () => _simulateEvent('jumat_incoming'), - ), - _simulasiCard( - s: s, - title: 'Khutbah Berlangsung', - icon: HugeIcons.strokeRoundedUserGroup, - desc: 'Menyimulasikan layar saat Khutbah sedang berlangsung tanpa hitungan mundur (2 menit setelah Adzan Dzuhur).', - onTap: () => _simulateEvent('jumat_khutbah'), - ), - _simulasiCard( - s: s, - title: 'Mode Shalat', - icon: HugeIcons.strokeRoundedMoon02, - desc: 'Layar menjadi hitam atau gelap selama shalat berlangsung.', - onTap: () => _simulateEvent('shalat'), - ), - ], + ), + child: Row( + children: [ + HugeIcon( + icon: isSimulating + ? HugeIcons.strokeRoundedAlert02 + : HugeIcons.strokeRoundedCheckmarkCircle02, + color: isSimulating + ? SacredColors.error + : SacredColors.primary, + size: 28 * s, + ), + SizedBox(width: 16 * s), + Expanded( + child: Text( + isSimulating + ? 'Simulasi aktif (${simulatedMinutes >= 0 ? '+' : ''}$simulatedMinutes menit). Gunakan kartu pertama untuk keluar dan kembali ke waktu asli.' + : 'Simulasi tidak aktif. Pilih salah satu skenario di bawah untuk mulai menguji tampilan layar.', + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w700, + color: SacredColors.onSurface, + ), + ), + ), + ], + ), + ), + SizedBox(height: 24 * s), + _simulasiCard( + s: s, + title: isSimulating ? 'Keluar dari Simulasi' : 'Gunakan Waktu Asli', + icon: isSimulating + ? HugeIcons.strokeRoundedCancelCircle + : HugeIcons.strokeRoundedHome01, + desc: isSimulating + ? 'Matikan mode simulasi dan kembali ke waktu sistem saat ini.' + : 'Pastikan aplikasi berjalan menggunakan waktu asli perangkat.', + onTap: () => _activateSimulation( + () => _simulateTimeOffset(Duration.zero), + ), + focusNode: _simulasiFocusNodes[0], + rowIndex: 0, + ), + SizedBox(height: 16 * s), + _simulasiCard( + s: s, + title: 'Menuju Adzan', + icon: HugeIcons.strokeRoundedClock01, + desc: 'Melompat ke 2 menit sebelum Adzan Dzuhur hari ini.', + onTap: () => _activateSimulation( + () => _simulateEvent('pre_adzan'), + ), + focusNode: _simulasiFocusNodes[1], + rowIndex: 1, + ), + SizedBox(height: 16 * s), + _simulasiCard( + s: s, + title: 'Selama Adzan', + icon: HugeIcons.strokeRoundedMegaphone01, + desc: 'Melompat ke tepat waktu Adzan Dzuhur berkumandang.', + onTap: () => _activateSimulation( + () => _simulateEvent('adzan'), + ), + focusNode: _simulasiFocusNodes[2], + rowIndex: 2, + ), + SizedBox(height: 16 * s), + _simulasiCard( + s: s, + title: 'Menuju Iqomah', + icon: HugeIcons.strokeRoundedTimer02, + desc: 'Melompat ke saat waktu iqomah sedang menghitung mundur (1 menit setelah Adzan).', + onTap: () => _activateSimulation( + () => _simulateEvent('iqomah'), + ), + focusNode: _simulasiFocusNodes[3], + rowIndex: 3, + ), + SizedBox(height: 16 * s), + _simulasiCard( + s: s, + title: 'Persiapan Jumat', + icon: HugeIcons.strokeRoundedCalendar03, + desc: 'Menyimulasikan layar khusus persiapan Jumat (30 menit sebelum Adzan Dzuhur).', + onTap: () => _activateSimulation( + () => _simulateEvent('jumat_incoming'), + ), + focusNode: _simulasiFocusNodes[4], + rowIndex: 4, + ), + SizedBox(height: 16 * s), + _simulasiCard( + s: s, + title: 'Khutbah Berlangsung', + icon: HugeIcons.strokeRoundedUserGroup, + desc: 'Menyimulasikan layar saat Khutbah sedang berlangsung tanpa hitungan mundur (2 menit setelah Adzan Dzuhur).', + onTap: () => _activateSimulation( + () => _simulateEvent('jumat_khutbah'), + ), + focusNode: _simulasiFocusNodes[5], + rowIndex: 5, + ), + SizedBox(height: 16 * s), + _simulasiCard( + s: s, + title: 'Mode Shalat', + icon: HugeIcons.strokeRoundedMoon02, + desc: 'Layar menjadi hitam atau gelap selama shalat berlangsung.', + onTap: () => _activateSimulation( + () => _simulateEvent('shalat'), + ), + focusNode: _simulasiFocusNodes[6], + rowIndex: 6, ), ], ), + ), + ), ); } - Widget _simulasiCard({required double s, required String title, required dynamic icon, required String desc, required VoidCallback onTap}) { - return _tvFocusable( - s: s, - radius: SacredRadii.lg, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(SacredRadii.lg), - child: Container( - width: 320 * s, - padding: EdgeInsets.all(24 * s), - decoration: BoxDecoration( - color: SacredColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(SacredRadii.lg), - border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.5)), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10 * s, offset: Offset(0, 4 * s)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - HugeIcon(icon: icon, color: SacredColors.primary, size: 40 * s), - SizedBox(height: 16 * s), - Text(title, style: GoogleFonts.plusJakartaSans(fontSize: 20 * s, fontWeight: FontWeight.bold, color: SacredColors.onSurface)), - SizedBox(height: 8 * s), - Text(desc, style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)), - ], - ), + Widget _simulasiCard({ + required double s, + required String title, + required dynamic icon, + required String desc, + required VoidCallback onTap, + required int rowIndex, + FocusNode? focusNode, + }) { + final node = focusNode ?? _simulasiFocusNodes[rowIndex]; + return _scrollAware( + controller: _simulasiScrollController, + child: Focus( + focusNode: node, + onKeyEvent: (focusNode, event) => _handleSimulasiActionKey( + rowIndex, + event, + onActivate: onTap, + ), + child: ListenableBuilder( + listenable: node, + builder: (context, _) { + final isFocused = node.hasFocus; + return AnimatedScale( + scale: isFocused ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(isFocused ? 5 * s : 0), + decoration: BoxDecoration( + color: isFocused + ? SacredColors.surfaceContainerLow.withValues(alpha: 0.96) + : Colors.transparent, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: Border.all( + color: isFocused + ? SacredColors.primary.withValues(alpha: 0.95) + : Colors.transparent, + width: isFocused ? 3 : 0, + ), + boxShadow: isFocused + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(SacredRadii.lg), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(24 * s), + decoration: BoxDecoration( + color: SacredColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: Border.all( + color: SacredColors.outlineVariant.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10 * s, + offset: Offset(0, 4 * s), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + HugeIcon( + icon: icon, + color: SacredColors.primary, + size: 40 * s, + ), + SizedBox(height: 16 * s), + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 20 * s, + fontWeight: FontWeight.bold, + color: SacredColors.onSurface, + ), + ), + SizedBox(height: 8 * s), + Text( + desc, + style: GoogleFonts.manrope( + fontSize: 14 * s, + color: SacredColors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ); + }, ), ), ); @@ -2451,9 +3583,6 @@ class _AdminScreenState extends ConsumerState { void _simulateTimeOffset(Duration offset) { ref.read(mockTimeOffsetProvider.notifier).state = offset; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Offset Waktu disetel ke: ${offset.inMinutes} Menit', style: GoogleFonts.manrope())), - ); } void _simulateEvent(String eventType) { @@ -2502,6 +3631,16 @@ class _AdminScreenState extends ConsumerState { final offset = targetTime.difference(realNow); _simulateTimeOffset(offset); } + + void _activateSimulation(VoidCallback action) { + action(); + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }); + } } class _NavButton extends StatefulWidget { @@ -2509,6 +3648,9 @@ class _NavButton extends StatefulWidget { final dynamic icon; final bool isActive; final double scale; + final FocusNode? focusNode; + final ValueChanged? onFocusChange; + final FocusOnKeyEventCallback? onKeyEvent; final VoidCallback onTap; const _NavButton({ @@ -2516,6 +3658,9 @@ class _NavButton extends StatefulWidget { required this.icon, required this.isActive, required this.scale, + this.focusNode, + this.onFocusChange, + this.onKeyEvent, required this.onTap, }); @@ -2523,6 +3668,534 @@ class _NavButton extends StatefulWidget { State<_NavButton> createState() => _NavButtonState(); } +class _TvAdjustTile extends StatefulWidget { + final double scale; + final FocusNode? focusNode; + final String label; + final String valueLabel; + final double progress; + final String helperText; + final VoidCallback? onMoveLeft; + final VoidCallback? onMoveUp; + final VoidCallback? onMoveDown; + final VoidCallback onIncrement; + final VoidCallback onDecrement; + + const _TvAdjustTile({ + required this.scale, + this.focusNode, + required this.label, + required this.valueLabel, + required this.progress, + required this.helperText, + this.onMoveLeft, + this.onMoveUp, + this.onMoveDown, + required this.onIncrement, + required this.onDecrement, + }); + + @override + State<_TvAdjustTile> createState() => _TvAdjustTileState(); +} + +class _TvEditableTextTile extends StatefulWidget { + final double scale; + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + final int maxLines; + final TextInputType? keyboardType; + final ValueChanged? onChanged; + final VoidCallback? onEditComplete; + final VoidCallback? onMoveLeft; + final VoidCallback? onMoveUp; + final VoidCallback? onMoveDown; + + const _TvEditableTextTile({ + required this.scale, + required this.label, + required this.controller, + this.focusNode, + this.maxLines = 1, + this.keyboardType, + this.onChanged, + this.onEditComplete, + this.onMoveLeft, + this.onMoveUp, + this.onMoveDown, + }); + + @override + State<_TvEditableTextTile> createState() => _TvEditableTextTileState(); +} + +class _TvAdjustTileState extends State<_TvAdjustTile> { + late final FocusNode _fallbackFocusNode = + FocusNode(debugLabel: 'tv_adjust_tile'); + bool _isFocused = false; + bool _isEditing = false; + + FocusNode get _focusNode => widget.focusNode ?? _fallbackFocusNode; + + @override + void dispose() { + if (widget.focusNode == null) { + _fallbackFocusNode.dispose(); + } + super.dispose(); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { + setState(() => _isEditing = !_isEditing); + return KeyEventResult.handled; + } + + if (!_isEditing && key == LogicalKeyboardKey.arrowUp) { + widget.onMoveUp?.call(); + return KeyEventResult.handled; + } + if (!_isEditing && key == LogicalKeyboardKey.arrowDown) { + widget.onMoveDown?.call(); + return KeyEventResult.handled; + } + if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) { + widget.onMoveLeft?.call(); + return KeyEventResult.handled; + } + if (!_isEditing && key == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.handled; + } + + if (!_isEditing) return KeyEventResult.ignored; + + if (key == LogicalKeyboardKey.arrowLeft) { + widget.onDecrement(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowRight) { + widget.onIncrement(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.escape) { + setState(() => _isEditing = false); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final s = widget.scale; + final highlight = _isFocused || _isEditing; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKey, + onFocusChange: (value) { + setState(() { + _isFocused = value; + if (!value) _isEditing = false; + }); + }, + child: InkWell( + onTap: _focusNode.requestFocus, + borderRadius: BorderRadius.circular(SacredRadii.md), + child: AnimatedScale( + scale: highlight ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(18 * s), + decoration: BoxDecoration( + color: highlight + ? SacredColors.surfaceContainerLow + : SacredColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(SacredRadii.md), + border: Border.all( + color: highlight + ? SacredColors.primary.withValues(alpha: 0.95) + : SacredColors.outlineVariant.withValues(alpha: 0.25), + width: highlight ? 3 : 1, + ), + boxShadow: highlight + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.label, + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w600, + color: SacredColors.onSurface, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 14 * s, + vertical: 6 * s, + ), + decoration: BoxDecoration( + color: SacredColors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(SacredRadii.sm), + border: Border.all( + color: SacredColors.primary.withValues(alpha: 0.35), + ), + ), + child: Text( + widget.valueLabel, + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w800, + color: SacredColors.primary, + ), + ), + ), + ], + ), + SizedBox(height: 14 * s), + Row( + children: [ + Container( + width: 36 * s, + height: 36 * s, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _isEditing + ? SacredColors.surfaceContainerHigh + : SacredColors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(SacredRadii.sm), + border: Border.all( + color: _isEditing + ? SacredColors.primary.withValues(alpha: 0.8) + : SacredColors.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Text( + '←', + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w800, + color: SacredColors.onSurface, + ), + ), + ), + SizedBox(width: 12 * 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: widget.progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: SacredColors.primary, + borderRadius: BorderRadius.circular(3 * s), + ), + ), + ), + ), + ), + SizedBox(width: 12 * s), + Container( + width: 36 * s, + height: 36 * s, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _isEditing + ? SacredColors.surfaceContainerHigh + : SacredColors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(SacredRadii.sm), + border: Border.all( + color: _isEditing + ? SacredColors.primary.withValues(alpha: 0.8) + : SacredColors.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Text( + '→', + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w800, + color: SacredColors.onSurface, + ), + ), + ), + ], + ), + SizedBox(height: 10 * s), + Text( + _isEditing + ? 'Mode ubah aktif. Gunakan ← → lalu tekan OK untuk selesai.' + : widget.helperText, + style: GoogleFonts.manrope( + fontSize: 11 * s, + color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TvEditableTextTileState extends State<_TvEditableTextTile> { + late final FocusNode _fallbackFocusNode = + FocusNode(debugLabel: 'tv_edit_tile'); + late final FocusNode _textFocusNode = + FocusNode(debugLabel: 'tv_edit_text'); + bool _isFocused = false; + bool _isEditing = false; + + FocusNode get _outerFocusNode => widget.focusNode ?? _fallbackFocusNode; + + @override + void initState() { + super.initState(); + _textFocusNode.canRequestFocus = false; + _textFocusNode.addListener(_handleTextFocusChange); + } + + @override + void dispose() { + _textFocusNode.removeListener(_handleTextFocusChange); + _textFocusNode.dispose(); + if (widget.focusNode == null) { + _fallbackFocusNode.dispose(); + } + super.dispose(); + } + + void _handleTextFocusChange() { + if (!mounted) return; + if (!_textFocusNode.hasFocus && _isEditing) { + _finishEditing(); + return; + } + setState(() {}); + } + + void _startEditing() { + setState(() { + _isEditing = true; + _textFocusNode.canRequestFocus = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _textFocusNode.requestFocus(); + } + }); + } + + void _finishEditing() { + if (!mounted) return; + setState(() { + _isEditing = false; + _textFocusNode.canRequestFocus = false; + }); + widget.onEditComplete?.call(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _outerFocusNode.requestFocus(); + } + }); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + if (!_isEditing && + (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.select)) { + _startEditing(); + return KeyEventResult.handled; + } + + if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) { + widget.onMoveLeft?.call(); + return KeyEventResult.handled; + } + + if (!_isEditing && key == LogicalKeyboardKey.arrowUp) { + widget.onMoveUp?.call(); + return KeyEventResult.handled; + } + + if (!_isEditing && key == LogicalKeyboardKey.arrowDown) { + widget.onMoveDown?.call(); + return KeyEventResult.handled; + } + + if (_isEditing && key == LogicalKeyboardKey.escape) { + _finishEditing(); + return KeyEventResult.handled; + } + + if (_isEditing && + widget.maxLines == 1 && + (key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.select)) { + _finishEditing(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final s = widget.scale; + final highlight = _isFocused || _isEditing || _textFocusNode.hasFocus; + + return Focus( + focusNode: _outerFocusNode, + onKeyEvent: _handleKey, + onFocusChange: (value) { + setState(() { + _isFocused = value; + }); + }, + child: InkWell( + onTap: () { + if (_isEditing) { + _textFocusNode.requestFocus(); + } else { + _startEditing(); + } + }, + borderRadius: BorderRadius.circular(SacredRadii.md), + child: AnimatedScale( + scale: highlight ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(18 * s), + decoration: BoxDecoration( + color: highlight + ? SacredColors.surfaceContainerLow + : SacredColors.surfaceContainerLowest, + borderRadius: BorderRadius.circular(SacredRadii.md), + border: Border.all( + color: highlight + ? SacredColors.primary.withValues(alpha: 0.95) + : SacredColors.outlineVariant.withValues(alpha: 0.35), + width: highlight ? 3 : 1, + ), + boxShadow: highlight + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: GoogleFonts.manrope( + fontSize: 16 * s, + fontWeight: FontWeight.w600, + color: SacredColors.onSurfaceVariant, + ), + ), + SizedBox(height: 12 * s), + AbsorbPointer( + absorbing: !_isEditing, + child: TextField( + focusNode: _textFocusNode, + controller: widget.controller, + maxLines: widget.maxLines, + keyboardType: widget.keyboardType, + readOnly: !_isEditing, + showCursor: _isEditing, + style: GoogleFonts.plusJakartaSans( + fontSize: 24 * s, + color: SacredColors.onSurface, + ), + decoration: InputDecoration( + filled: true, + fillColor: SacredColors.surfaceContainerLowest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(SacredRadii.md), + borderSide: BorderSide( + color: SacredColors.outlineVariant.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(SacredRadii.md), + borderSide: const BorderSide( + color: SacredColors.primary, + width: 2, + ), + ), + ), + onChanged: widget.onChanged, + onSubmitted: (_) { + if (widget.maxLines == 1) { + _finishEditing(); + } + }, + ), + ), + SizedBox(height: 8 * s), + Text( + _isEditing + ? 'Mode edit aktif. Tekan ESC untuk selesai.' + : 'Tekan OK untuk mulai edit.', + style: GoogleFonts.manrope( + fontSize: 11 * s, + color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + class _TvFocusFrame extends StatefulWidget { final Widget child; final double scale; @@ -2553,32 +4226,37 @@ class _TvFocusFrameState extends State<_TvFocusFrame> { setState(() => _hasFocus = value); } }, - child: AnimatedContainer( + child: AnimatedScale( + scale: _hasFocus ? 1.01 : 1.0, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, - padding: EdgeInsets.all(_hasFocus ? 4 * s : 0), - decoration: BoxDecoration( - color: _hasFocus - ? SacredColors.primary.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: widget.borderRadius, - border: Border.all( + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: EdgeInsets.all(_hasFocus ? 5 * s : 0), + decoration: BoxDecoration( color: _hasFocus - ? SacredColors.primary.withValues(alpha: 0.7) + ? SacredColors.surfaceContainerLow.withValues(alpha: 0.96) : Colors.transparent, - width: _hasFocus ? 2 : 0, + borderRadius: widget.borderRadius, + border: Border.all( + color: _hasFocus + ? SacredColors.primary.withValues(alpha: 0.95) + : Colors.transparent, + width: _hasFocus ? 3 : 0, + ), + boxShadow: _hasFocus + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.28), + blurRadius: 24 * s, + spreadRadius: 2 * s, + ), + ] + : null, ), - boxShadow: _hasFocus - ? [ - BoxShadow( - color: SacredColors.primary.withValues(alpha: 0.18), - blurRadius: 18 * s, - spreadRadius: 1 * s, - ), - ] - : null, + child: widget.child, ), - child: widget.child, ), ); } @@ -2592,50 +4270,73 @@ class _NavButtonState extends State<_NavButton> { 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, + return Focus( + focusNode: widget.focusNode, + onFocusChange: widget.onFocusChange, + onKeyEvent: widget.onKeyEvent, + child: 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: AnimatedScale( + scale: highlight ? 1.01 : 1.0, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s), + decoration: BoxDecoration( color: highlight - ? SacredColors.onPrimaryContainer - : SacredColors.onSurfaceVariant, - size: 28 * s, + ? SacredColors.surfaceContainerLow + : Colors.transparent, + borderRadius: BorderRadius.circular(SacredRadii.lg), + border: highlight + ? Border.all( + color: SacredColors.primary.withValues(alpha: 0.95), + width: 3, + ) + : null, + boxShadow: highlight + ? [ + BoxShadow( + color: SacredColors.primary.withValues(alpha: 0.24), + blurRadius: 22 * s, + spreadRadius: 1 * s, + ), + ] + : null, ), - 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, + child: Row( + children: [ + HugeIcon( + icon: widget.icon, + color: highlight + ? SacredColors.onSurface + : 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.onSurface + : SacredColors.onSurfaceVariant, + letterSpacing: 1 * s, + ), ), ), + ], ), - ], + ), ), ), ), diff --git a/lib/features/home/home_view.dart b/lib/features/home/home_view.dart index 68f2b20..74306d9 100644 --- a/lib/features/home/home_view.dart +++ b/lib/features/home/home_view.dart @@ -41,7 +41,9 @@ class _HomeViewState extends ConsumerState { final FocusNode _homeFocusNode = FocusNode(debugLabel: 'home_tv_root'); final List _recentKeys = []; + final List _simulationShortcutKeys = []; Timer? _comboResetTimer; + Timer? _simulationShortcutTimer; @override void initState() { @@ -57,6 +59,7 @@ class _HomeViewState extends ConsumerState { @override void dispose() { _comboResetTimer?.cancel(); + _simulationShortcutTimer?.cancel(); _homeFocusNode.dispose(); super.dispose(); } @@ -79,6 +82,50 @@ class _HomeViewState extends ConsumerState { if (event is! KeyDownEvent) return KeyEventResult.ignored; final key = event.logicalKey; + final isSimulating = ref.read(mockTimeOffsetProvider) != Duration.zero; + if (isSimulating && key == LogicalKeyboardKey.arrowLeft) { + _comboResetTimer?.cancel(); + _recentKeys.clear(); + _simulationShortcutTimer?.cancel(); + _simulationShortcutTimer = Timer( + const Duration(seconds: 2), + _resetSimulationShortcut, + ); + _simulationShortcutKeys.add(key); + if (_simulationShortcutKeys.length > 2) { + _simulationShortcutKeys.removeAt(0); + } + if (_matchesSimulationShortcut()) { + _resetSimulationShortcut(); + ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AdminScreen( + initialTab: 4, + focusSelectedTabOnOpen: true, + ), + ), + ); + if (mounted) { + _homeFocusNode.requestFocus(); + } + }); + } + return KeyEventResult.handled; + } else if (isSimulating) { + _resetSimulationShortcut(); + } + + if (isSimulating && + (key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.browserBack)) { + ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero; + return KeyEventResult.handled; + } + if (!_isComboKey(key)) { _resetCombo(); return KeyEventResult.ignored; @@ -135,6 +182,17 @@ class _HomeViewState extends ConsumerState { _recentKeys.clear(); } + bool _matchesSimulationShortcut() { + return _simulationShortcutKeys.length == 2 && + _simulationShortcutKeys[0] == LogicalKeyboardKey.arrowLeft && + _simulationShortcutKeys[1] == LogicalKeyboardKey.arrowLeft; + } + + void _resetSimulationShortcut() { + _simulationShortcutTimer?.cancel(); + _simulationShortcutKeys.clear(); + } + @override Widget build(BuildContext context) { // Audio trigger listener @@ -189,8 +247,6 @@ class _HomeViewState extends ConsumerState { break; } - final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero; - return Focus( autofocus: true, focusNode: _homeFocusNode, @@ -206,32 +262,6 @@ class _HomeViewState extends ConsumerState { }, child: screen, ), - - if (isSimulating) - Positioned( - right: 64, - bottom: 64, - child: ElevatedButton.icon( - onPressed: () { - ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero; - }, - icon: const Icon(Icons.cancel, color: Colors.white), - label: const Text( - 'BATALKAN SIMULASI', - style: TextStyle( - fontWeight: FontWeight.bold, - letterSpacing: 2, - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade800, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - elevation: 10, - ), - ), - ), ], ), ),