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; }); if (!result.updateAvailable && _selectedTab == 5 && _tentangFocusNodes[1].hasFocus) { _focusTentangRow(0); } _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 >= _tentangRowCount()) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _tentangFocusNodes[index].requestFocus(); } }); } int _tentangRowCount() { return (_updateCheckResult?.updateAvailable ?? false) ? 2 : 1; } 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: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(SacredRadii.lg), child: InkWell( onTap: onActivate, borderRadius: BorderRadius.circular(SacredRadii.lg), 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: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(SacredRadii.lg), child: InkWell( onTap: onActivate, borderRadius: BorderRadius.circular(SacredRadii.lg), 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: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(SacredRadii.lg), child: InkWell( onTap: onActivate, borderRadius: BorderRadius.circular(SacredRadii.lg), 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', ); }, onProgressChanged: (nextProgress) { final mapped = (minOffset + ((maxOffset - minOffset) * nextProgress)).round(); final clamped = mapped.clamp(minOffset, maxOffset).toInt(); if (clamped == _hijriOffsetDays) return; setState(() { _hijriOffsetDays = clamped; }); _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'; final denominator = max - min; final progress = denominator <= 0 ? 0.0 : ((value - min) / denominator); return _scrollAware( controller: _scrollControllerForTab(_selectedTab), child: _buildTvAdjustTile( s: s, focusNode: focusNode, label: label, valueLabel: valueLabel, progress: progress.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(); }, onProgressChanged: denominator <= 0 ? null : (nextProgress) { final mapped = (min + ((max - min) * nextProgress)) .round() .clamp(min, max) .toInt(); if (mapped == _parseCtrlInt(controller, fallback)) return; setState(() { controller.text = mapped.toString(); }); 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, ValueChanged? onProgressChanged, }) { return _TvAdjustTile( scale: s, focusNode: focusNode, label: label, valueLabel: valueLabel, progress: progress, helperText: helperText, onMoveLeft: onMoveLeft, onMoveUp: onMoveUp, onMoveDown: onMoveDown, onIncrement: onIncrement, onDecrement: onDecrement, onProgressChanged: onProgressChanged, ); } 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)), onProgressChanged: (nextProgress) { final mapped = (0.5 + (1.5 * nextProgress)).clamp(0.5, 2.0); final snapped = (((mapped / step).round() * step).clamp(0.5, 2.0)).toDouble(); onChanged(snapped); }, ); } 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?.versionName ?? '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, 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', ), ), if (updateResult?.updateAvailable ?? false) ...[ 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)}%' : 'UPDATE SEKARANG', ), ), ], ], ), ), ], ), ), ), ); } 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'; if (remote.latestVersion == result.current.versionName) { return 'Update build tersedia untuk versi ${remote.latestVersion}'; } return 'Update tersedia ke ${remote.latestVersion}'; } 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; final ValueChanged? onProgressChanged; 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, this.onProgressChanged, }); @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; void _updateFromTouchPosition(double x, double width) { if (widget.onProgressChanged == null || width <= 0) return; final normalized = (x / width).clamp(0.0, 1.0); widget.onProgressChanged!(normalized); } @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: [ GestureDetector( onTap: () { _focusNode.requestFocus(); widget.onDecrement(); }, child: 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: LayoutBuilder( builder: (context, constraints) { final barWidth = constraints.maxWidth; return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) { _focusNode.requestFocus(); _updateFromTouchPosition(details.localPosition.dx, barWidth); }, onHorizontalDragStart: widget.onProgressChanged == null ? null : (_) { _focusNode.requestFocus(); setState(() => _isEditing = true); }, onHorizontalDragUpdate: widget.onProgressChanged == null ? null : (details) => _updateFromTouchPosition( details.localPosition.dx, barWidth, ), onHorizontalDragEnd: widget.onProgressChanged == null ? null : (_) => setState(() => _isEditing = false), onHorizontalDragCancel: widget.onProgressChanged == null ? null : () => setState(() => _isEditing = false), 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), GestureDetector( onTap: () { _focusNode.requestFocus(); widget.onIncrement(); }, child: 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, ), ), ), ], ), ), ), ), ), ); } }