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'; import 'package:intl/intl.dart'; import '../../core/sacred_tokens.dart'; import '../../providers.dart'; import '../../data/services/sync_service.dart'; import '../../data/services/myquran_service.dart'; import '../../data/services/sound_service.dart'; import '../../data/services/update_service.dart'; import 'package:file_picker/file_picker.dart'; import 'dart:io'; class AdminScreen extends ConsumerStatefulWidget { final int initialTab; final bool focusSelectedTabOnOpen; const AdminScreen({ super.key, this.initialTab = 0, this.focusSelectedTabOnOpen = false, }); @override ConsumerState createState() => _AdminScreenState(); } class _AdminScreenState extends ConsumerState { final _masjidNameCtrl = TextEditingController(); final _masjidAddressCtrl = TextEditingController(); final _cityCtrl = TextEditingController(); // Displays DisplayName or CityID final _mainDurCtrl = TextEditingController(); final _slideDurCtrl = TextEditingController(); int _selectedTab = 0; bool _isSyncing = false; int _textScaleIndex = 1; List _slideshowImages = []; bool _useUnsplash = false; final _unsplashKeywordCtrl = TextEditingController(); final _unsplashRotationCtrl = TextEditingController(); // Branded background String? _brandedBgImage; // Running text repeater String _marqueeAnimType = 'marquee'; List _runningTexts = []; List _runningTextDurations = []; // Granular text group scales double _scaleCardLabel = 1.0; double _scaleCardBody = 1.0; double _scaleRunningText = 1.0; // Jumat fields final _khatibCtrl = TextEditingController(); final _imamCtrl = TextEditingController(); // Iqomah Jeda fields final _iqomahSubuhCtrl = TextEditingController(); final _iqomahDzuhurCtrl = TextEditingController(); final _iqomahAsharCtrl = TextEditingController(); final _iqomahMaghribCtrl = TextEditingController(); final _iqomahIsyaCtrl = TextEditingController(); final _preAdzanLeadCtrl = TextEditingController(); final _blankNormalCtrl = TextEditingController(); final _blankJumatCtrl = TextEditingController(); final _identityScrollController = ScrollController(); final _jadwalScrollController = ScrollController(); final _tampilanScrollController = ScrollController(); final _jumatScrollController = ScrollController(); final _simulasiScrollController = ScrollController(); final _tentangScrollController = ScrollController(); late final FocusNode _identityEntryFocusNode; late final FocusNode _tampilanEntryFocusNode; late final FocusNode _jumatEntryFocusNode; late final FocusNode _simulasiEntryFocusNode; late final FocusNode _tentangEntryFocusNode; late final List _navFocusNodes; late final List _jadwalFocusNodes; late final List _identityFocusNodes; late final List _jumatFocusNodes; late final List _simulasiFocusNodes; late final List _tentangFocusNodes; final Map _tampilanFocusNodes = {}; Timer? _identityAutoSaveTimer; Timer? _tampilanAutoSaveTimer; Timer? _jumatAutoSaveTimer; Timer? _jadwalAutoSaveTimer; Timer? _statusBadgeTimer; String? _statusBadgeMessage; bool _statusBadgeIsError = false; int _hijriOffsetDays = 0; AppVersionInfo? _currentVersion; UpdateCheckResult? _updateCheckResult; bool _isCheckingUpdate = false; bool _isInstallingUpdate = false; double _updateDownloadProgress = 0; @override void initState() { super.initState(); _selectedTab = widget.initialTab.clamp(0, 5); _identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry'); _tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry'); _jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry'); _simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry'); _tentangEntryFocusNode = FocusNode(debugLabel: 'tentang_entry'); _navFocusNodes = List.generate( 6, (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( 8, (index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'), ), ]; _tentangFocusNodes = [ _tentangEntryFocusNode, FocusNode(debugLabel: 'tentang_row_1'), ]; _jadwalFocusNodes = List.generate( 11, (index) => FocusNode(debugLabel: 'jadwal_row_$index'), ); final settings = ref.read(settingsProvider); _masjidNameCtrl.text = settings.masjidName; _masjidAddressCtrl.text = settings.masjidAddress; _cityCtrl.text = '${settings.cityDisplayName} (${settings.cityIdApi})'; _mainDurCtrl.text = settings.mainScreenDurationSec.toString(); _slideDurCtrl.text = settings.slideDurationSec.toString(); _textScaleIndex = settings.textScaleIndex; _slideshowImages = List.from(settings.slideshowImages); _useUnsplash = settings.useUnsplashBackground; _unsplashKeywordCtrl.text = settings.unsplashKeyword; _unsplashRotationCtrl.text = settings.unsplashRotationHours.toString(); _brandedBgImage = settings.brandedBgImage; _marqueeAnimType = settings.marqueeAnimType; _runningTexts = List.from(settings.runningTexts); _runningTextDurations = List.from( settings.runningTextDurations.isNotEmpty ? settings.runningTextDurations : List.filled(settings.runningTexts.length, 12), ); // Ensure durations list length matches texts while (_runningTextDurations.length < _runningTexts.length) { _runningTextDurations.add(12); } _scaleCardLabel = settings.scaleCardLabel; _scaleCardBody = settings.scaleCardBody; _scaleRunningText = settings.scaleRunningText; _khatibCtrl.text = settings.khatibName; _imamCtrl.text = settings.imamName; _iqomahSubuhCtrl.text = settings.iqomahSubuh.toString(); _iqomahDzuhurCtrl.text = settings.iqomahDzuhur.toString(); _iqomahAsharCtrl.text = settings.iqomahAshar.toString(); _iqomahMaghribCtrl.text = settings.iqomahMaghrib.toString(); _iqomahIsyaCtrl.text = settings.iqomahIsya.toString(); _preAdzanLeadCtrl.text = settings.preAdzanLead.toString(); _blankNormalCtrl.text = settings.blankScreenNormal.toString(); _blankJumatCtrl.text = settings.blankScreenJumat.toString(); _hijriOffsetDays = settings.hijriOffsetDays; _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); } }); unawaited(_loadCurrentVersion()); } @override void dispose() { _masjidNameCtrl.dispose(); _masjidAddressCtrl.dispose(); _cityCtrl.dispose(); _mainDurCtrl.dispose(); _slideDurCtrl.dispose(); _unsplashKeywordCtrl.dispose(); _unsplashRotationCtrl.dispose(); _khatibCtrl.dispose(); _imamCtrl.dispose(); _iqomahSubuhCtrl.dispose(); _iqomahDzuhurCtrl.dispose(); _iqomahAsharCtrl.dispose(); _iqomahMaghribCtrl.dispose(); _iqomahIsyaCtrl.dispose(); _preAdzanLeadCtrl.dispose(); _blankNormalCtrl.dispose(); _blankJumatCtrl.dispose(); _identityScrollController.dispose(); _jadwalScrollController.dispose(); _tampilanScrollController.dispose(); _jumatScrollController.dispose(); _simulasiScrollController.dispose(); _tentangScrollController.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 _tentangFocusNodes) { node.dispose(); } for (final node in _jadwalFocusNodes) { node.dispose(); } for (final node in _tampilanFocusNodes.values) { node.dispose(); } super.dispose(); } 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(); // cityId is saved instantly when selected from dialog return s; }); if (mounted) { _showStatusBadge(message); } } 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); s.mainScreenDurationSec = int.tryParse(_mainDurCtrl.text.trim()) ?? 15; s.slideDurationSec = int.tryParse(_slideDurCtrl.text.trim()) ?? 10; s.useUnsplashBackground = _useUnsplash; s.unsplashKeyword = _unsplashKeywordCtrl.text.trim().isEmpty ? 'mosque' : _unsplashKeywordCtrl.text.trim(); s.unsplashRotationHours = int.tryParse(_unsplashRotationCtrl.text.trim()) ?? 6; s.brandedBgImage = _brandedBgImage; s.runningTexts = List.from(_runningTexts); s.runningTextDurations = List.from(_runningTextDurations); s.marqueeAnimType = _marqueeAnimType; s.scaleCardLabel = _scaleCardLabel; s.scaleCardBody = _scaleCardBody; s.scaleRunningText = _scaleRunningText; return s; }); if (mounted) { _showStatusBadge(message); } } 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; s.blankScreenJumat = int.tryParse(_blankJumatCtrl.text.trim()) ?? 45; s.iqomahSubuh = int.tryParse(_iqomahSubuhCtrl.text.trim()) ?? 15; s.iqomahDzuhur = int.tryParse(_iqomahDzuhurCtrl.text.trim()) ?? 10; s.iqomahAshar = int.tryParse(_iqomahAsharCtrl.text.trim()) ?? 10; s.iqomahMaghrib = int.tryParse(_iqomahMaghribCtrl.text.trim()) ?? 10; s.iqomahIsya = int.tryParse(_iqomahIsyaCtrl.text.trim()) ?? 10; s.hijriOffsetDays = _hijriOffsetDays; return s; }); if (mounted) { ref.invalidate(hijriDateProvider); _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 _loadCurrentVersion() async { final version = await UpdateService.instance.getCurrentVersion(); if (!mounted) return; setState(() { _currentVersion = version; }); } Future _checkForUpdates() async { if (_isCheckingUpdate) return; setState(() => _isCheckingUpdate = true); final result = await UpdateService.instance.checkForUpdate(); if (!mounted) return; setState(() { _isCheckingUpdate = false; _currentVersion = result.current; _updateCheckResult = result; }); _showStatusBadge( result.hasError ? result.errorMessage! : result.updateAvailable ? 'Update baru tersedia' : 'Versi ini sudah terbaru', isError: result.hasError, ); } Future _installLatestUpdate() async { final result = _updateCheckResult; final remote = result?.remote; if (_isInstallingUpdate || result == null || remote == null) return; if (!result.updateAvailable) { _showStatusBadge('Versi ini sudah terbaru'); return; } setState(() { _isInstallingUpdate = true; _updateDownloadProgress = 0; }); final installResult = await UpdateService.instance.downloadAndTriggerInstall( remote, onProgress: (progress) { if (!mounted) return; setState(() { _updateDownloadProgress = progress.clamp(0, 1); }); }, ); if (!mounted) return; setState(() { _isInstallingUpdate = false; }); _showStatusBadge(installResult.message, isError: !installResult.success); } Future _syncData() async { setState(() => _isSyncing = true); final success = await SyncService.instance.syncMonthlyData(); setState(() => _isSyncing = false); if (mounted) { ref.invalidate(todayScheduleProvider); ref.invalidate(scheduleCacheStatusProvider); _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; 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(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: 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 Widget build(BuildContext context) { final size = MediaQuery.of(context).size; final s = size.width / 1920; return Scaffold( backgroundColor: SacredColors.background, appBar: AppBar( backgroundColor: SacredColors.surfaceContainerLowest, title: Text( 'PENGATURAN SISTEM', style: GoogleFonts.plusJakartaSans( fontSize: 24 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface, letterSpacing: 2 * s, ), ), 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(), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Nav rail area Container( width: 350 * s, color: SacredColors.surfaceContainerLow, padding: EdgeInsets.all(32 * s), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _NavButton( title: 'IDENTITAS MASJID', 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), _NavButton( title: 'JADWAL & SINKRONISASI', 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), _NavButton( title: 'TAMPILAN & MEDIA', 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), _NavButton( title: 'PENGATURAN JUMAT', 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), _NavButton( title: 'SIMULASI', 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), ), SizedBox(height: 16 * s), _NavButton( title: 'TENTANG', icon: HugeIcons.strokeRoundedInformationCircle, isActive: _selectedTab == 5, scale: s, focusNode: _navFocusNodes[5], onFocusChange: (focused) { if (focused) _setSelectedTab(5); }, onKeyEvent: (node, event) => _handleNavKey(5, event), onTap: () => setState(() => _selectedTab = 5), ), ], ), ), // Content area Expanded( child: Padding( padding: EdgeInsets.all(64 * s), child: _selectedTab == 0 ? _buildIdentityTab(s) : _selectedTab == 1 ? _buildJadwalTab(s) : _selectedTab == 2 ? _buildTampilanTab(s) : _selectedTab == 3 ? _buildJumatTab(s) : _selectedTab == 4 ? _buildSimulasiTab(s) : _buildTentangTab(s), ), ), ], ), ), ); } void _setSelectedTab(int index) { if (_selectedTab == index) return; setState(() => _selectedTab = index); } void _focusNavTab(int index) { if (index < 0 || index >= _navFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _navFocusNodes[index].requestFocus(); } }); } void _focusIdentityRow(int index) { if (_selectedTab != 0) return; if (index < 0 || index >= _identityFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _identityFocusNodes[index].requestFocus(); } }); } void _focusJumatRow(int index) { if (_selectedTab != 3) return; if (index < 0 || index >= _jumatFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _jumatFocusNodes[index].requestFocus(); } }); } void _focusSimulasiRow(int index) { if (_selectedTab != 4) return; if (index < 0 || index >= _simulasiFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _simulasiFocusNodes[index].requestFocus(); } }); } void _focusTentangRow(int index) { if (_selectedTab != 5) return; if (index < 0 || index >= _tentangFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _tentangFocusNodes[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; case 5: _focusTentangRow(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; 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; 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; } KeyEventResult _handleTentangActionKey( 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) { _focusTentangRow(index - 1); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown) { _focusTentangRow(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 FocusTraversalGroup( policy: WidgetOrderTraversalPolicy(), child: Focus( canRequestFocus: false, onKeyEvent: (node, event) => _handleSimpleTabKey(event), child: SingleChildScrollView( controller: _jumatScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Pengaturan Jumat', style: GoogleFonts.plusJakartaSans( fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.secondary), ), SizedBox(height: 8 * s), Text( 'Data di bawah akan tampil setiap hari Jumat: pada layar utama (banner bawah jam) dan layar Persiapan Khutbah.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 40 * s), _adminCard(s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionLabel('Petugas Shalat Jumat', s), SizedBox(height: 8 * s), Text( 'Nama Khatib dan Imam tampil di layar utama setiap Jumat dan di layar Persiapan Khutbah.', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 24 * 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, focusNode: _jumatFocusNodes[1], onMoveLeft: () => _focusNavTab(_selectedTab), onMoveUp: () => _focusJumatRow(0), ), SizedBox(height: 32 * s), // Preview chip if (_khatibCtrl.text.isNotEmpty || _imamCtrl.text.isNotEmpty) ...[ Text('Preview tampilan:', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)), SizedBox(height: 10 * s), Container( padding: EdgeInsets.all(20 * s), decoration: BoxDecoration( color: SacredColors.background, borderRadius: BorderRadius.circular(SacredRadii.lg), border: Border.all(color: SacredColors.secondary.withValues(alpha: 0.2)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.star_rounded, color: SacredColors.secondary, size: 16 * s), SizedBox(width: 8 * s), Text('JUMAT MUBARAK', style: GoogleFonts.plusJakartaSans( fontSize: 14 * s, fontWeight: FontWeight.w800, color: SacredColors.secondary, letterSpacing: 2)), SizedBox(width: 8 * s), Icon(Icons.star_rounded, color: SacredColors.secondary, size: 16 * s), SizedBox(width: 24 * s), if (_khatibCtrl.text.isNotEmpty) Text('KHATIB ${_khatibCtrl.text}', style: GoogleFonts.manrope( fontSize: 14 * s, color: SacredColors.onSurface)), if (_khatibCtrl.text.isNotEmpty && _imamCtrl.text.isNotEmpty) Text(' | ', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)), if (_imamCtrl.text.isNotEmpty) Text('IMAM ${_imamCtrl.text}', style: GoogleFonts.manrope( fontSize: 14 * s, color: SacredColors.onSurface)), ], ), ), SizedBox(height: 24 * s), ], ], )), SizedBox(height: 32 * s), // Info box _adminCard(s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionLabel('Kapan Digunakan?', s), SizedBox(height: 16 * s), _infoRow(Icons.tv, 'Layar Utama (Jumat)', 'Banner bawah jam berubah ke JUMAT MUBARAK, nama khatib & imam tampil di bawahnya.', s), SizedBox(height: 12 * s), _infoRow(Icons.timer_outlined, 'Layar Persiapan Khutbah', 'Saat menuju iqomah Dzuhur di hari Jumat, layar menampilkan judul PERSIAPAN KHUTBAH beserta nama petugas.', s), SizedBox(height: 12 * s), _infoRow(Icons.info_outline, 'Durasi Blank Screen', 'Durasi Black Screen setelah shalat Jumat dapat diatur di tab Jadwal & Sinkronisasi.', s), ], )), ], ), ), ), ); } Widget _infoRow(IconData icon, String title, String desc, double s) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: SacredColors.secondary, size: 22 * s), SizedBox(width: 12 * s), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: GoogleFonts.manrope(fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)), SizedBox(height: 4 * s), Text(desc, style: GoogleFonts.manrope(fontSize: 13 * s, color: SacredColors.onSurfaceVariant)), ], ), ), ], ); } Widget _buildTampilanTab(double s) { 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( 'Pengaturan Tampilan & Media', style: GoogleFonts.plusJakartaSans( fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.primary, ), ), SizedBox(height: 48 * s), _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: 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, errorBuilder: (_, __, ___) => Container( height: 180 * s, width: double.infinity, color: SacredColors.surfaceContainerLowest, alignment: Alignment.center, child: Icon( Icons.broken_image, size: 36 * s, color: SacredColors.onSurfaceVariant, ), ), ), ), 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, ), ), ), ), ] 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); final selectedPath = res?.files.single.path; if (selectedPath != null && File(selectedPath).existsSync()) { setState(() => _brandedBgImage = selectedPath); _queueTampilanAutoSave( message: 'Foto latar otomatis tersimpan', ); } }, child: ElevatedButton.icon( onPressed: () async { final res = await FilePicker.platform.pickFiles(type: FileType.image); final selectedPath = res?.files.single.path; if (selectedPath != null && File(selectedPath).existsSync()) { setState(() => _brandedBgImage = selectedPath); _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 && File(path).existsSync() && !_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 && File(path).existsSync() && !_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)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(SacredRadii.sm), child: Image.file( File(path), width: double.infinity, height: 120 * s, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: double.infinity, height: 120 * s, color: SacredColors.surfaceContainerHigh, alignment: Alignment.center, child: Icon( Icons.broken_image, size: 32 * s, color: SacredColors.onSurfaceVariant, ), ), ), ), 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, 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.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: 24 * s), _adminCard(s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _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), if (_runningTexts.isEmpty) Padding( padding: EdgeInsets.symmetric(vertical: 16 * s), child: Text('Belum ada teks. Klik TAMBAH untuk menambah baris.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)), ) else ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _runningTexts.length, separatorBuilder: (_, __) => SizedBox(height: 12 * s), itemBuilder: (context, idx) { final textCtrl = TextEditingController(text: _runningTexts[idx]) ..selection = TextSelection.fromPosition(TextPosition(offset: _runningTexts[idx].length)); final durCtrl = TextEditingController(text: _runningTextDurations[idx].toString()); return Container( padding: EdgeInsets.all(20 * s), decoration: BoxDecoration( color: SacredColors.surfaceContainerLowest, borderRadius: BorderRadius.circular(SacredRadii.md), border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 32 * s, height: 32 * s, alignment: Alignment.center, decoration: BoxDecoration( color: SacredColors.surfaceContainerHighest, shape: BoxShape.circle, ), child: Text('${idx + 1}', style: GoogleFonts.manrope(fontSize: 14 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)), ), 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(height: 12 * s), SizedBox( width: 180 * s, child: _TvEditableTextTile( scale: s, label: 'Durasi (detik)', focusNode: _tampilanFocusNode(runningTextDurationRows[idx]), controller: durCtrl, keyboardType: TextInputType.number, 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(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), _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(height: 40 * s), ], ), ), ), ); } Widget _adminCard(double s, {required Widget child}) { return _scrollAware( controller: _scrollControllerForTab(_selectedTab), child: _TvFocusFrame( scale: s, borderRadius: BorderRadius.circular(SacredRadii.xl), child: Container( width: double.infinity, padding: EdgeInsets.all(36 * s), decoration: BoxDecoration( color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(SacredRadii.xl), border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)), ), child: child, ), ), ); } Widget _tvFocusable({ required Widget child, required double s, double radius = SacredRadii.md, bool scrollAware = true, }) { final framed = _TvFocusFrame( scale: s, borderRadius: BorderRadius.circular(radius), child: child, ); if (!scrollAware) return framed; return _scrollAware( controller: _scrollControllerForTab(_selectedTab), child: framed, ); } 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 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 _buildReadonlyField( TextEditingController controller, double s, { bool focusable = true, FocusNode? focusNode, VoidCallback? onMoveLeft, VoidCallback? onMoveUp, VoidCallback? onMoveDown, }) { final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Lokasi Saat Ini', style: GoogleFonts.manrope( fontSize: 16 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurfaceVariant, ), ), 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: 24 * s, color: SacredColors.onSurface, ), ), ), ], ); 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 _buildJadwalActionButton({ required int rowIndex, required double s, required VoidCallback onActivate, Widget? child, Widget Function(bool isFocused)? builder, }) { 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!, ), ), ); }, ), ), ); } 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 _buildTentangActionButton({ required int rowIndex, required double s, required VoidCallback onActivate, Widget? child, Widget Function(bool isFocused)? builder, }) { assert(child != null || builder != null); final focusNode = _tentangFocusNodes[rowIndex]; return _scrollAware( controller: _tentangScrollController, child: Focus( focusNode: focusNode, onKeyEvent: (node, event) => _handleTentangActionKey( 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!, ), ), ); }, ), ), ); } 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), ], ), ), ), ], ), ); } Widget _sectionLabel(String label, double s) { return Text( label, style: GoogleFonts.plusJakartaSans( fontSize: 20 * s, fontWeight: FontWeight.w700, color: SacredColors.primary, ), ); } Widget _buildIdentityTab(double s) { 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: [ 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: 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(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, ), ), ), ); }, ), ), ), ], ), ), ], ), ), ), ); } Widget _buildJadwalTab(double s) { final settings = ref.watch(settingsProvider); final todayScheduleOption = ref.watch(todayScheduleProvider); final cacheStatus = ref.watch(scheduleCacheStatusProvider); final displayedHijri = ref.watch(hijriDateProvider).valueOrNull; final cacheRangeLabel = cacheStatus.hasData ? '${_formatCacheDate(cacheStatus.startDate)} - ${_formatCacheDate(cacheStatus.endDate)}' : 'Belum ada data'; return SingleChildScrollView( controller: _jadwalScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Jadwal & Sinkronisasi', style: GoogleFonts.plusJakartaSans( fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.primary, ), ), SizedBox(height: 48 * s), // Sync Card Container( width: double.infinity, padding: EdgeInsets.all(40 * s), decoration: BoxDecoration( color: SacredColors.surfaceContainerLow, borderRadius: BorderRadius.circular(SacredRadii.xl), border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.4)), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Status Data Jadwal', style: GoogleFonts.manrope(fontSize: 20 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurfaceVariant, letterSpacing: 1 * s), ), SizedBox(height: 24 * s), Wrap( spacing: 48 * s, runSpacing: 20 * s, children: [ _buildStatusRow('Terakhir Sync', settings.lastSyncDate ?? 'Belum pernah', HugeIcons.strokeRoundedClock01, s), _buildStatusRow('Sumber Data', 'api.myquran.com', HugeIcons.strokeRoundedDatabase01, s), _buildStatusRow('Lokasi Data', settings.cityDisplayName, HugeIcons.strokeRoundedLocation01, s), _buildStatusRow('Cache Tersimpan', cacheRangeLabel, HugeIcons.strokeRoundedCalendar03, s), _buildStatusRow('Jumlah Hari', cacheStatus.hasData ? '${cacheStatus.cachedDays} hari' : '0 hari', HugeIcons.strokeRoundedTaskDaily01, s), _buildStatusRow('Status Update', _buildCacheUpdateLabel(cacheStatus, todayScheduleOption != null), HugeIcons.strokeRoundedAlert02, s), ], ), ], ), ), ], ), ), 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( s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionLabel('Kalender Hijriah', s), SizedBox(height: 8 * s), Text( 'Sesuaikan tampilan tanggal Hijriah jika hasil rukyat lokal masjid berbeda dari nilai default API.', style: GoogleFonts.manrope( fontSize: 14 * s, color: SacredColors.onSurfaceVariant, ), ), SizedBox(height: 24 * s), Container( width: double.infinity, padding: EdgeInsets.all(24 * s), decoration: BoxDecoration( color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(SacredRadii.lg), border: Border.all( color: SacredColors.outlineVariant.withValues(alpha: 0.2), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Tanggal tampil saat ini', style: GoogleFonts.manrope( fontSize: 14 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurfaceVariant, ), ), SizedBox(height: 8 * s), Text( displayedHijri ?? 'Memuat tanggal Hijriah...', style: GoogleFonts.plusJakartaSans( fontSize: 28 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface, ), ), ], ), Container( padding: EdgeInsets.symmetric( horizontal: 16 * s, vertical: 10 * s, ), decoration: BoxDecoration( color: SacredColors.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(SacredRadii.full), ), child: Text( 'Offset ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari', style: GoogleFonts.plusJakartaSans( fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.primary, ), ), ), ], ), ), SizedBox(height: 20 * s), _buildHijriOffsetControl( s, focusNode: _jadwalFocusNodes[1], onMoveLeft: () => _focusNavTab(_selectedTab), onMoveUp: () => _focusJadwalRow(0), onMoveDown: () => _focusJadwalRow(2), ), SizedBox(height: 16 * s), _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'), ), ), ], ), ), SizedBox(height: 64 * s), // Waktu & Durasi Card _adminCard(s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionLabel('Waktu & Durasi', s), SizedBox(height: 8 * s), Text( 'Seluruh pengaturan angka utama untuk alur jadwal ditangani dengan stepper agar nyaman dipakai dengan remote Android TV.', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 32 * s), _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( 'Jeda Waktu Iqamah (Menit)', style: GoogleFonts.manrope( fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface, ), ), SizedBox(height: 8 * s), Text( 'Tentukan durasi hitung mundur dari selesai Adzan hingga iqamah untuk tiap shalat fardhu.', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 24 * s), _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), _buildTvIntStepperField( s: s, 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, ), ], )), SizedBox(height: 64 * s), Text( 'Pratinjau Jadwal Hari Ini', style: GoogleFonts.plusJakartaSans( fontSize: 32 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface, ), ), SizedBox(height: 32 * s), // Schedule Grid Builder( builder: (context) { if (todayScheduleOption == null) { return Padding( padding: EdgeInsets.symmetric(vertical: 24 * s), child: Center( child: Text('Data jadwal kosong. Silakan lakukan sinkronisasi.', style: GoogleFonts.manrope(fontSize: 24 * s, color: SacredColors.error)), ), ); } final prayerMap = { 'IMSAK': todayScheduleOption.imsak, 'SUBUH': todayScheduleOption.subuh, 'TERBIT': todayScheduleOption.terbit, 'DHUHA': todayScheduleOption.dhuha, 'DZUHUR': todayScheduleOption.dzuhur, 'ASHAR': todayScheduleOption.ashar, 'MAGHRIB': todayScheduleOption.maghrib, 'ISYA': todayScheduleOption.isya, }; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, crossAxisSpacing: 24 * s, mainAxisSpacing: 24 * s, childAspectRatio: 2.2, // wide rectangular Google Stitch cards ), itemCount: prayerMap.length, itemBuilder: (context, index) { final key = prayerMap.keys.elementAt(index); final time = prayerMap[key]!; return _buildPrayerCard(key, time, s); }, ); }, ), SizedBox(height: 32 * s), ], ), ); } Widget _buildHijriOffsetControl( double s, { FocusNode? focusNode, VoidCallback? onMoveLeft, VoidCallback? onMoveUp, VoidCallback? onMoveDown, }) { const minOffset = -3; const maxOffset = 3; 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', ); }, ); } Widget _buildPrayerCard(String name, String time, double s) { return Container( decoration: BoxDecoration( color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(SacredRadii.lg), border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), blurRadius: 10 * s, offset: Offset(0, 4 * s), ) ] ), padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 24 * s), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( name, style: GoogleFonts.manrope( fontSize: 18 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurfaceVariant, letterSpacing: 2 * s, ), ), SizedBox(height: 8 * s), Text( time, style: GoogleFonts.plusJakartaSans( fontSize: 42 * s, fontWeight: FontWeight.w800, color: SacredColors.primary, ), ), ], ), ); } ScrollController _scrollControllerForTab(int tabIndex) { switch (tabIndex) { case 0: return _identityScrollController; case 1: return _jadwalScrollController; case 2: return _tampilanScrollController; case 3: return _jumatScrollController; case 4: return _simulasiScrollController; case 5: return _tentangScrollController; default: return _identityScrollController; } } Widget _scrollAware({ required ScrollController controller, required Widget child, }) { return Builder( builder: (context) { return Focus( onFocusChange: (hasFocus) { if (!hasFocus || !controller.hasClients) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { Scrollable.ensureVisible( context, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, alignment: 0.18, ); } }); }, child: child, ); }, ); } int _parseCtrlInt(TextEditingController ctrl, int fallback) { return int.tryParse(ctrl.text.trim()) ?? fallback; } void _bumpCtrlInt( TextEditingController ctrl, { required int delta, required int min, required int max, required int fallback, }) { final next = (_parseCtrlInt(ctrl, fallback) + delta).clamp(min, max); setState(() { ctrl.text = next.toString(); }); } Widget _buildTvIntStepperField({ required double s, required String label, FocusNode? focusNode, required TextEditingController controller, required int fallback, required int min, required int max, String suffix = '', 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: _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 _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: _TvEditableTextTile( scale: s, label: label, controller: ctrl, focusNode: focusNode, maxLines: maxLines, keyboardType: keyboardType, onChanged: onChanged, onEditComplete: onEditComplete, onMoveLeft: onMoveLeft, onMoveUp: onMoveUp, onMoveDown: onMoveDown, ), ); } Widget _buildStatusRow(String label, String value, dynamic icon, double s) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( padding: EdgeInsets.all(12 * s), decoration: BoxDecoration( color: SacredColors.surfaceContainerHighest, borderRadius: BorderRadius.circular(SacredRadii.sm), ), child: HugeIcon(icon: icon, color: SacredColors.secondary, size: 24 * s), ), SizedBox(width: 16 * s), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: GoogleFonts.manrope(fontSize: 12 * s, color: SacredColors.onSurfaceVariant)), Text(value, style: GoogleFonts.plusJakartaSans(fontSize: 18 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurface)), ], ), ], ); } String _formatCacheDate(DateTime? date) { if (date == null) return 'Belum ada'; return DateFormat('dd MMM yyyy').format(date); } String _buildCacheUpdateLabel( ScheduleCacheStatus status, bool hasTodayData, ) { if (!status.hasData) return 'Belum ada cache'; if (!hasTodayData) return 'Hari ini belum tersimpan'; if (status.daysUntilRefresh < 0) { return 'Lewat ${-status.daysUntilRefresh} hari'; } if (status.daysUntilRefresh == 0) return 'Update hari ini'; if (status.daysUntilRefresh == 1) return '1 hari lagi'; return '${status.daysUntilRefresh} hari lagi'; } Widget _scaleSlider({ required double s, 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; return _buildTvAdjustTile( s: s, 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) { 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( 'Mode Simulasi Pengembang', style: GoogleFonts.plusJakartaSans( fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.primary, ), ), SizedBox(height: 16 * s), Text( 'Gunakan tombol di bawah ini untuk melihat pratinjau bagaimana aplikasi bereaksi terhadap berbagai waktu dan status tanpa harus menunggu waktu sebenarnya.\nFitur ini bekerja dengan menggeser waktu aplikasi (Time Travel).', style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.onSurfaceVariant), ), SizedBox(height: 48 * s), 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), ), ), 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: '15 Detik Sebelum Adzan', icon: HugeIcons.strokeRoundedNotification03, desc: 'Melompat ke 15 detik sebelum Adzan Dzuhur untuk memeriksa transisi terakhir menuju Adzan.', onTap: () => _activateSimulation( () => _simulateEvent('pre_adzan_15'), ), focusNode: _simulasiFocusNodes[1], rowIndex: 1, ), 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[2], rowIndex: 2, ), 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[3], rowIndex: 3, ), SizedBox(height: 16 * s), _simulasiCard( s: s, title: '15 Detik Sebelum Iqamah', icon: HugeIcons.strokeRoundedTimer02, desc: 'Melompat ke 15 detik sebelum Iqamah Dzuhur untuk memeriksa hitungan mundur terakhir.', onTap: () => _activateSimulation( () => _simulateEvent('pre_iqomah_15'), ), focusNode: _simulasiFocusNodes[4], rowIndex: 4, ), 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[5], rowIndex: 5, ), 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[6], rowIndex: 6, ), 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[7], rowIndex: 7, ), 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[8], rowIndex: 8, ), ], ), ), ), ); } Widget _buildTentangTab(double s) { final currentVersion = _currentVersion; final updateResult = _updateCheckResult; final remote = updateResult?.remote; return FocusTraversalGroup( policy: WidgetOrderTraversalPolicy(), child: Focus( canRequestFocus: false, onKeyEvent: (node, event) => _handleSimpleTabKey(event), child: SingleChildScrollView( controller: _tentangScrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Tentang Aplikasi', style: GoogleFonts.plusJakartaSans( fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.primary, ), ), SizedBox(height: 16 * s), Text( 'Informasi aplikasi, kontak bantuan, dan pemeriksaan versi terbaru.', style: GoogleFonts.manrope( fontSize: 18 * s, color: SacredColors.onSurfaceVariant, ), ), SizedBox(height: 40 * s), _adminCard( s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionLabel('Kontak Bantuan', s), SizedBox(height: 20 * s), _buildStatusRow( 'Nama Pengembang', 'Dwindi Ramadhana', HugeIcons.strokeRoundedUser, s, ), SizedBox(height: 20 * s), _buildStatusRow( 'Alamat', 'Yogyakarta, Indonesia', HugeIcons.strokeRoundedLocation01, s, ), SizedBox(height: 20 * s), _buildStatusRow( 'Nomor Kontak', '+62 812 2988 6864', HugeIcons.strokeRoundedCall02, s, ), ], ), ), SizedBox(height: 32 * s), _adminCard( s, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionLabel('Versi & Pembaruan', s), SizedBox(height: 20 * s), _buildStatusRow( 'Versi Saat Ini', currentVersion?.displayLabel ?? 'Memuat versi...', HugeIcons.strokeRoundedPackage, s, ), SizedBox(height: 20 * s), _buildStatusRow( 'Sumber Update', 'files.jamshalat.com/latest.json', HugeIcons.strokeRoundedLinkCircle02, s, ), if (updateResult != null) ...[ SizedBox(height: 20 * s), _buildStatusRow( 'Status', _buildUpdateStatusLabel(updateResult), updateResult.hasError ? HugeIcons.strokeRoundedAlert02 : updateResult.updateAvailable ? HugeIcons.strokeRoundedArrowDown01 : HugeIcons.strokeRoundedCheckmarkCircle02, s, ), ], if (remote != null) ...[ SizedBox(height: 20 * s), _buildStatusRow( 'Versi Remote', '${remote.latestVersion}+${remote.versionCode}', HugeIcons.strokeRoundedPackage, s, ), if (remote.publishedAt != null) ...[ SizedBox(height: 20 * s), _buildStatusRow( 'Tanggal Rilis', DateFormat( 'dd MMM yyyy, HH:mm', 'id_ID', ).format(remote.publishedAt!.toLocal()), HugeIcons.strokeRoundedCalendar03, s, ), ], if (remote.notes.isNotEmpty) ...[ SizedBox(height: 24 * s), 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.35, ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Catatan Rilis', style: GoogleFonts.manrope( fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurfaceVariant, ), ), SizedBox(height: 12 * s), Text( remote.notes, style: GoogleFonts.manrope( fontSize: 18 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurface, height: 1.5, ), ), ], ), ), ], ], SizedBox(height: 24 * s), _buildTentangActionButton( rowIndex: 0, s: s, onActivate: _checkForUpdates, builder: (isFocused) => _buildTvPrimaryActionSurface( s: s, isFocused: isFocused, icon: _isCheckingUpdate ? SizedBox( width: 24 * s, height: 24 * s, child: CircularProgressIndicator( color: isFocused ? SacredColors.onPrimary : SacredColors.onSecondary, strokeWidth: 3, ), ) : HugeIcon( icon: HugeIcons.strokeRoundedRefresh, color: isFocused ? SacredColors.onPrimary : SacredColors.onSecondary, ), label: _isCheckingUpdate ? 'MEMERIKSA UPDATE...' : 'CEK UPDATE', ), ), SizedBox(height: 16 * s), _buildTentangActionButton( rowIndex: 1, s: s, onActivate: _installLatestUpdate, builder: (isFocused) => _buildTvPrimaryActionSurface( s: s, isFocused: isFocused, icon: _isInstallingUpdate ? SizedBox( width: 24 * s, height: 24 * s, child: CircularProgressIndicator( color: isFocused ? SacredColors.onPrimary : SacredColors.onSecondary, strokeWidth: 3, ), ) : HugeIcon( icon: HugeIcons.strokeRoundedArrowDown01, color: isFocused ? SacredColors.onPrimary : SacredColors.onSecondary, ), label: _isInstallingUpdate ? 'MENGUNDUH UPDATE ${(100 * _updateDownloadProgress).toStringAsFixed(0)}%' : (updateResult?.updateAvailable ?? false) ? 'UPDATE SEKARANG' : 'BELUM ADA UPDATE', ), ), ], ), ), ], ), ), ), ); } String _buildUpdateStatusLabel(UpdateCheckResult result) { if (result.hasError) { return result.errorMessage ?? 'Pemeriksaan update gagal'; } if (result.updateAvailable) { final remote = result.remote; if (remote == null) return 'Update tersedia'; return 'Update tersedia ke ${remote.latestVersion}+${remote.versionCode}'; } return 'Versi ini sudah terbaru'; } 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, ), ), ], ), ), ), ), ); }, ), ), ); } void _simulateTimeOffset(Duration offset) { ref.read(mockTimeOffsetProvider.notifier).state = offset; } void _simulateEvent(String eventType) { final schedule = ref.read(todayScheduleProvider); if (schedule == null) return; // We simulate using schedule.dzuhur final dzuhurStr = schedule.dzuhur; final parts = dzuhurStr.split(':'); final realNow = DateTime.now(); final dzuhurTime = DateTime(realNow.year, realNow.month, realNow.day, int.parse(parts[0]), int.parse(parts[1])); DateTime targetTime; switch (eventType) { case 'pre_adzan_15': targetTime = dzuhurTime.subtract(const Duration(seconds: 15)); break; case 'pre_adzan': targetTime = dzuhurTime.subtract(const Duration(minutes: 2)); break; case 'adzan': targetTime = dzuhurTime; break; case 'pre_iqomah_15': final settings = ref.read(settingsProvider); targetTime = dzuhurTime .add(Duration(minutes: settings.iqomahDzuhur)) .subtract(const Duration(seconds: 15)); break; case 'iqomah': targetTime = dzuhurTime.add(const Duration(seconds: 45)); // During iqomah break; case 'jumat_incoming': int diff = DateTime.friday - realNow.weekday; DateTime nextFriday = realNow.add(Duration(days: diff)); // Target: next Friday at dzuhur time - 30 minutes targetTime = DateTime(nextFriday.year, nextFriday.month, nextFriday.day, dzuhurTime.hour, dzuhurTime.minute).subtract(const Duration(minutes: 30)); break; case 'jumat_khutbah': int diff = DateTime.friday - realNow.weekday; DateTime nextFriday = realNow.add(Duration(days: diff)); // Target: next Friday at dzuhur time + 3 minutes (safely past 2-min Adzan) targetTime = DateTime(nextFriday.year, nextFriday.month, nextFriday.day, dzuhurTime.hour, dzuhurTime.minute).add(const Duration(minutes: 3)); break; case 'shalat': // Shalat mode usually happens after iqomah ends final settings = ref.read(settingsProvider); targetTime = dzuhurTime.add(Duration(minutes: settings.iqomahDzuhur + 1)); break; default: targetTime = realNow; } final offset = targetTime.difference(realNow); _simulateTimeOffset(offset); switch (eventType) { case 'adzan': unawaited(SoundService.instance.playAdzanBeep()); break; case 'iqomah': unawaited(SoundService.instance.playIqomahCountdown()); break; default: break; } } 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 { final String title; final dynamic icon; final bool isActive; final double scale; final FocusNode? focusNode; final ValueChanged? onFocusChange; final FocusOnKeyEventCallback? onKeyEvent; final VoidCallback onTap; const _NavButton({ required this.title, required this.icon, required this.isActive, required this.scale, this.focusNode, this.onFocusChange, this.onKeyEvent, required this.onTap, }); @override 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; final BorderRadius borderRadius; const _TvFocusFrame({ required this.child, required this.scale, required this.borderRadius, }); @override State<_TvFocusFrame> createState() => _TvFocusFrameState(); } class _TvFocusFrameState extends State<_TvFocusFrame> { bool _hasFocus = false; @override Widget build(BuildContext context) { final s = widget.scale; return Focus( canRequestFocus: false, descendantsAreFocusable: true, onFocusChange: (value) { if (_hasFocus != value) { setState(() => _hasFocus = value); } }, child: AnimatedScale( scale: _hasFocus ? 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(_hasFocus ? 5 * s : 0), decoration: BoxDecoration( color: _hasFocus ? SacredColors.surfaceContainerLow.withValues(alpha: 0.96) : Colors.transparent, 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, ), child: widget.child, ), ), ); } } class _NavButtonState extends State<_NavButton> { bool _isFocused = false; @override Widget build(BuildContext context) { final s = widget.scale; final highlight = widget.isActive || _isFocused; 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.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, ), 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, ), ), ), ], ), ), ), ), ), ); } }