Files
jamshalat-masjid-screen/lib/features/admin/admin_screen.dart
2026-03-31 14:00:29 +07:00

4346 lines
161 KiB
Dart

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 '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<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen> {
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<String> _slideshowImages = [];
bool _useUnsplash = false;
final _unsplashKeywordCtrl = TextEditingController();
final _unsplashRotationCtrl = TextEditingController();
// Branded background
String? _brandedBgImage;
// Running text repeater
String _marqueeAnimType = 'marquee';
List<String> _runningTexts = [];
List<int> _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();
late final FocusNode _identityEntryFocusNode;
late final FocusNode _tampilanEntryFocusNode;
late final FocusNode _jumatEntryFocusNode;
late final FocusNode _simulasiEntryFocusNode;
late final List<FocusNode> _navFocusNodes;
late final List<FocusNode> _jadwalFocusNodes;
late final List<FocusNode> _identityFocusNodes;
late final List<FocusNode> _jumatFocusNodes;
late final List<FocusNode> _simulasiFocusNodes;
final Map<int, FocusNode> _tampilanFocusNodes = {};
Timer? _identityAutoSaveTimer;
Timer? _tampilanAutoSaveTimer;
Timer? _jumatAutoSaveTimer;
Timer? _jadwalAutoSaveTimer;
Timer? _statusBadgeTimer;
String? _statusBadgeMessage;
bool _statusBadgeIsError = false;
int _hijriOffsetDays = 0;
@override
void initState() {
super.initState();
_selectedTab = widget.initialTab.clamp(0, 4);
_identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry');
_tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry');
_jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry');
_simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry');
_navFocusNodes = List.generate(
5,
(index) => FocusNode(debugLabel: 'admin_nav_$index'),
);
_identityFocusNodes = [
_identityEntryFocusNode,
...List.generate(
2,
(index) => FocusNode(debugLabel: 'identity_row_${index + 1}'),
),
];
_jumatFocusNodes = [
_jumatEntryFocusNode,
FocusNode(debugLabel: 'jumat_row_1'),
];
_simulasiFocusNodes = [
_simulasiEntryFocusNode,
...List.generate(
6,
(index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'),
),
];
_jadwalFocusNodes = List.generate(
11,
(index) => FocusNode(debugLabel: 'jadwal_row_$index'),
);
final settings = ref.read(settingsProvider);
_masjidNameCtrl.text = settings.masjidName;
_masjidAddressCtrl.text = settings.masjidAddress;
_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);
}
});
}
@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();
_tampilanEntryFocusNode.dispose();
_identityAutoSaveTimer?.cancel();
_tampilanAutoSaveTimer?.cancel();
_jumatAutoSaveTimer?.cancel();
_jadwalAutoSaveTimer?.cancel();
_statusBadgeTimer?.cancel();
for (final node in _navFocusNodes) {
node.dispose();
}
for (final node in _identityFocusNodes) {
node.dispose();
}
for (final node in _jumatFocusNodes) {
node.dispose();
}
for (final node in _simulasiFocusNodes) {
node.dispose();
}
for (final node in _jadwalFocusNodes) {
node.dispose();
}
for (final node in _tampilanFocusNodes.values) {
node.dispose();
}
super.dispose();
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _showCitySearchDialog(double s) async {
final queryCtrl = TextEditingController();
final queryFocusNode = FocusNode(debugLabel: 'city_query');
final searchFocusNode = FocusNode(debugLabel: 'city_search');
final resultsScrollController = ScrollController();
final resultFocusNodes = <FocusNode>[];
List<Map<String, dynamic>> results = [];
bool isSearching = false;
try {
await showDialog(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setDialogState) {
Future<void> selectCity(Map<String, dynamic> 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<void> 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),
),
],
),
),
// 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)
: _buildSimulasiTab(s),
),
),
],
),
),
);
}
void _setSelectedTab(int index) {
if (_selectedTab == index) return;
setState(() => _selectedTab = index);
}
void _traceNav(String message) {
assert(() {
debugPrint('[TV NAV] $message');
return true;
}());
}
void _focusNavTab(int index) {
if (index < 0 || index >= _navFocusNodes.length) return;
_traceNav('focus nav[$index]');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_navFocusNodes[index].requestFocus();
}
});
}
void _focusIdentityRow(int index) {
if (_selectedTab != 0) return;
if (index < 0 || index >= _identityFocusNodes.length) return;
_traceNav('focus identitas[$index]');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_identityFocusNodes[index].requestFocus();
}
});
}
void _focusJumatRow(int index) {
if (_selectedTab != 3) return;
if (index < 0 || index >= _jumatFocusNodes.length) return;
_traceNav('focus jumat[$index]');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_jumatFocusNodes[index].requestFocus();
}
});
}
void _focusSimulasiRow(int index) {
if (_selectedTab != 4) return;
if (index < 0 || index >= _simulasiFocusNodes.length) return;
_traceNav('focus simulasi[$index]');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_simulasiFocusNodes[index].requestFocus();
}
});
}
void _focusJadwalRow(int index) {
if (_selectedTab != 1) return;
if (index < 0 || index >= _jadwalFocusNodes.length) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_jadwalFocusNodes[index].requestFocus();
}
});
}
FocusNode _tampilanFocusNode(int index) {
if (index == 0) {
return _tampilanEntryFocusNode;
}
return _tampilanFocusNodes.putIfAbsent(
index,
() => FocusNode(debugLabel: 'tampilan_row_$index'),
);
}
int _tampilanRowCount() {
var count = 0;
count += 7;
if (_useUnsplash) {
count += 2;
}
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) {
count += 1;
}
count += 1;
count += 1;
count += _slideshowImages.length;
count += 1;
count += _runningTexts.length * 3;
count += 1;
return count;
}
void _focusTampilanRow(int index) {
if (_selectedTab != 2) return;
final max = _tampilanRowCount();
if (index < 0 || index >= max) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_tampilanFocusNode(index).requestFocus();
}
});
}
void _focusEntryForTab(int index) {
final FocusNode? target;
switch (index) {
case 0:
_focusIdentityRow(0);
return;
case 1:
_focusJadwalRow(0);
return;
case 2:
_focusTampilanRow(0);
return;
case 3:
_focusJumatRow(0);
return;
case 4:
_focusSimulasiRow(0);
return;
default:
target = null;
}
if (target == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
target!.requestFocus();
}
});
}
KeyEventResult _handleNavKey(int index, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowUp) {
_focusNavTab(index - 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
_focusNavTab(index + 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter) {
_focusEntryForTab(index);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleJadwalActionKey(
int index,
KeyEvent event, {
required VoidCallback onActivate,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowUp) {
_focusJadwalRow(index - 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
_focusJadwalRow(index + 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
_focusNavTab(_selectedTab);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
onActivate();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleSimpleTabKey(KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
_focusNavTab(_selectedTab);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleIdentityActionKey(
int index,
KeyEvent event, {
required VoidCallback onActivate,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
_traceNav('identitas[$index] key=$key');
if (key == LogicalKeyboardKey.arrowUp) {
_focusIdentityRow(index - 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
_focusIdentityRow(index + 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
_focusNavTab(_selectedTab);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
onActivate();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleTampilanActionKey(
int index,
KeyEvent event, {
required VoidCallback onActivate,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowUp) {
_focusTampilanRow(index - 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
_focusTampilanRow(index + 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
_focusNavTab(_selectedTab);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
onActivate();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult _handleSimulasiActionKey(
int index,
KeyEvent event, {
required VoidCallback onActivate,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
_traceNav('simulasi[$index] key=$key');
if (key == LogicalKeyboardKey.arrowUp) {
_focusSimulasiRow(index - 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
_focusSimulasiRow(index + 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
_focusNavTab(_selectedTab);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
onActivate();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Widget _buildJumatTab(double s) {
return 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<int>.generate(
_slideshowImages.length,
(_) => row++,
);
final marqueeModeRow = row++;
final runningTextTextRows = <int>[];
final runningTextDurationRows = <int>[];
final runningTextDeleteRows = <int>[];
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,
),
),
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);
if (res != null && res.files.single.path != null) {
setState(() => _brandedBgImage = res.files.single.path);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
}
},
child: ElevatedButton.icon(
onPressed: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image);
if (res != null && res.files.single.path != null) {
setState(() => _brandedBgImage = res.files.single.path);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
}
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s),
label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)),
style: _tvElevatedActionStyle(
s: s,
normalBackground: SacredColors.secondary,
normalForeground: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s),
fontSize: 16 * s,
),
),
),
],
),
),
SizedBox(height: 24 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Galeri Gambar Slideshow', s),
SizedBox(height: 16 * s),
_buildTampilanActionButton(
rowIndex: addSlideshowImageRow,
s: s,
onActivate: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
if (res != null) {
setState(() {
for (var path in res.paths) {
if (path != null && !_slideshowImages.contains(path)) {
_slideshowImages.add(path);
}
}
});
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
}
},
child: ElevatedButton.icon(
onPressed: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
if (res != null) {
setState(() {
for (var path in res.paths) {
if (path != null && !_slideshowImages.contains(path)) {
_slideshowImages.add(path);
}
}
});
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
}
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onPrimary, size: 18 * s),
label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)),
style: _tvElevatedActionStyle(
s: s,
normalBackground: SacredColors.secondary,
normalForeground: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 20 * s, vertical: 14 * s),
fontSize: 14 * s,
),
),
),
SizedBox(height: 16 * s),
if (_slideshowImages.isEmpty)
Text('Belum ada gambar slideshow.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant))
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _slideshowImages.length,
separatorBuilder: (_, __) => SizedBox(height: 12 * s),
itemBuilder: (context, idx) {
final path = _slideshowImages[idx];
return Container(
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
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,
),
),
SizedBox(height: 10 * s),
Text(
path.split('/').last,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurface),
),
SizedBox(height: 10 * s),
_buildTampilanActionButton(
rowIndex: slideshowDeleteRows[idx],
s: s,
onActivate: () {
setState(() => _slideshowImages.removeAt(idx));
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
},
child: OutlinedButton.icon(
onPressed: () {
setState(() => _slideshowImages.removeAt(idx));
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 18 * s),
label: Text(
'HAPUS FOTO',
style: GoogleFonts.plusJakartaSans(
fontSize: 13 * s,
fontWeight: FontWeight.w700,
color: SacredColors.error,
),
),
),
),
],
),
);
},
),
],
),
),
SizedBox(height: 24 * s),
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Running Text / Pengumuman', s),
SizedBox(height: 12 * s),
_buildTvChoiceField(
s: s,
rowIndex: marqueeModeRow,
label: 'Mode Animasi Running Text',
options: const ['Marquee', 'Fade In-Out'],
selectedIndex: _marqueeAnimType == 'fade' ? 1 : 0,
onChanged: (index) {
setState(() => _marqueeAnimType = index == 1 ? 'fade' : 'marquee');
_queueTampilanAutoSave();
},
),
SizedBox(height: 24 * s),
if (_runningTexts.isEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: 16 * s),
child: Text('Belum ada teks. Klik TAMBAH untuk menambah baris.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _runningTexts.length,
separatorBuilder: (_, __) => SizedBox(height: 12 * s),
itemBuilder: (context, idx) {
final textCtrl = TextEditingController(text: _runningTexts[idx])
..selection = TextSelection.fromPosition(TextPosition(offset: _runningTexts[idx].length));
final durCtrl = TextEditingController(text: _runningTextDurations[idx].toString());
return Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32 * s,
height: 32 * s,
alignment: Alignment.center,
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Text('${idx + 1}', style: GoogleFonts.manrope(fontSize: 14 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)),
),
SizedBox(height: 12 * s),
_TvEditableTextTile(
scale: s,
label: 'Teks Pengumuman',
focusNode: _tampilanFocusNode(runningTextTextRows[idx]),
controller: textCtrl,
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusTampilanRow(
idx == 0 ? marqueeModeRow : runningTextDeleteRows[idx - 1],
),
onMoveDown: () => _focusTampilanRow(runningTextDurationRows[idx]),
onChanged: (val) {
_runningTexts[idx] = val;
},
onEditComplete: () {
_queueTampilanAutoSave(
message: 'Teks berjalan otomatis tersimpan',
);
},
),
SizedBox(height: 12 * s),
SizedBox(
width: 180 * s,
child: _TvEditableTextTile(
scale: s,
label: 'Durasi (detik)',
focusNode: _tampilanFocusNode(runningTextDurationRows[idx]),
controller: durCtrl,
keyboardType: TextInputType.number,
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusTampilanRow(runningTextTextRows[idx]),
onMoveDown: () => _focusTampilanRow(runningTextDeleteRows[idx]),
onChanged: (val) {
_runningTextDurations[idx] = int.tryParse(val) ?? 12;
},
onEditComplete: () {
_queueTampilanAutoSave(
message: 'Teks berjalan otomatis tersimpan',
);
},
),
),
SizedBox(height: 10 * s),
_buildTampilanActionButton(
rowIndex: runningTextDeleteRows[idx],
s: s,
onActivate: () {
setState(() {
_runningTexts.removeAt(idx);
_runningTextDurations.removeAt(idx);
});
_queueTampilanAutoSave(
message: 'Teks berjalan otomatis tersimpan',
);
},
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_runningTexts.removeAt(idx);
_runningTextDurations.removeAt(idx);
});
_queueTampilanAutoSave(
message: 'Teks berjalan otomatis tersimpan',
);
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 18 * s),
label: Text(
'HAPUS BARIS',
style: GoogleFonts.plusJakartaSans(
fontSize: 13 * s,
fontWeight: FontWeight.w700,
color: SacredColors.error,
),
),
),
),
],
),
);
},
),
SizedBox(height: 20 * s),
_buildTampilanActionButton(
rowIndex: addRunningTextRow,
s: s,
onActivate: () {
setState(() {
_runningTexts.add('');
_runningTextDurations.add(12);
});
_queueTampilanAutoSave(
message: 'Baris teks otomatis ditambahkan',
);
},
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_runningTexts.add('');
_runningTextDurations.add(12);
});
_queueTampilanAutoSave(
message: 'Baris teks otomatis ditambahkan',
);
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.primary, size: 20 * s),
label: Text('TAMBAH BARIS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, color: SacredColors.primary)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: SacredColors.primary.withValues(alpha: 0.5)),
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s),
),
),
),
],
)),
SizedBox(height: 40 * s),
],
),
),
),
);
}
Widget _adminCard(double s, {required Widget child}) {
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: _TvFocusFrame(
scale: s,
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(36 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)),
),
child: child,
),
),
);
}
Widget _tvFocusable({
required Widget child,
required double s,
double radius = SacredRadii.md,
bool scrollAware = true,
}) {
final framed = _TvFocusFrame(
scale: s,
borderRadius: BorderRadius.circular(radius),
child: child,
);
if (!scrollAware) return framed;
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: framed,
);
}
ButtonStyle _tvElevatedActionStyle({
required double s,
required Color normalBackground,
required Color normalForeground,
EdgeInsetsGeometry? padding,
double radius = SacredRadii.lg,
double fontSize = 16,
FontWeight fontWeight = FontWeight.bold,
}) {
return ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return normalBackground.withValues(alpha: 0.45);
}
if (states.contains(WidgetState.focused)) {
return SacredColors.primary;
}
return normalBackground;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return normalForeground.withValues(alpha: 0.6);
}
if (states.contains(WidgetState.focused)) {
return SacredColors.onPrimary;
}
return normalForeground;
}),
textStyle: WidgetStatePropertyAll(
TextStyle(fontSize: fontSize, fontWeight: fontWeight),
),
padding: WidgetStatePropertyAll(
padding ?? EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)),
),
);
}
Widget _buildReadonlyField(
TextEditingController controller,
double s, {
bool focusable = true,
FocusNode? focusNode,
VoidCallback? onMoveLeft,
VoidCallback? onMoveUp,
VoidCallback? onMoveDown,
}) {
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lokasi Saat Ini',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 12 * s),
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 18 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.35),
),
),
child: Text(
controller.text,
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
color: SacredColors.onSurface,
),
),
),
],
);
if (!focusable) {
return content;
}
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: Focus(
focusNode: focusNode,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowLeft) {
onMoveLeft?.call();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
onMoveUp?.call();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
onMoveDown?.call();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: _tvFocusable(
s: s,
scrollAware: false,
child: content,
),
),
);
}
Widget _buildJadwalActionButton({
required int rowIndex,
required double s,
required VoidCallback onActivate,
Widget? child,
Widget Function(bool isFocused)? builder,
}) {
assert(child != null || builder != null);
final focusNode = _jadwalFocusNodes[rowIndex];
return _scrollAware(
controller: _jadwalScrollController,
child: Focus(
focusNode: focusNode,
onKeyEvent: (node, event) =>
_handleJadwalActionKey(rowIndex, event, onActivate: onActivate),
child: ListenableBuilder(
listenable: focusNode,
builder: (context, _) {
final isFocused = focusNode.hasFocus;
return AnimatedScale(
scale: isFocused ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
decoration: BoxDecoration(
color: isFocused
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
: Colors.transparent,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: isFocused
? SacredColors.primary.withValues(alpha: 0.95)
: Colors.transparent,
width: isFocused ? 3 : 0,
),
boxShadow: isFocused
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: ExcludeFocus(
child: builder?.call(isFocused) ?? child!,
),
),
);
},
),
),
);
}
Widget _buildTampilanActionButton({
required int rowIndex,
required double s,
required VoidCallback onActivate,
Widget? child,
Widget Function(bool isFocused)? builder,
}) {
assert(child != null || builder != null);
final focusNode = _tampilanFocusNode(rowIndex);
return _scrollAware(
controller: _tampilanScrollController,
child: Focus(
focusNode: focusNode,
onKeyEvent: (node, event) => _handleTampilanActionKey(
rowIndex,
event,
onActivate: onActivate,
),
child: ListenableBuilder(
listenable: focusNode,
builder: (context, _) {
final isFocused = focusNode.hasFocus;
return AnimatedScale(
scale: isFocused ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
decoration: BoxDecoration(
color: isFocused
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
: Colors.transparent,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: isFocused
? SacredColors.primary.withValues(alpha: 0.95)
: Colors.transparent,
width: isFocused ? 3 : 0,
),
boxShadow: isFocused
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: builder != null ? builder(isFocused) : child!,
),
);
},
),
),
);
}
Widget _buildTvChoiceField({
required double s,
required int rowIndex,
required String label,
required List<String> options,
required int selectedIndex,
required ValueChanged<int> 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<bool> onChanged,
String trueLabel = 'Aktif',
String falseLabel = 'Nonaktif',
}) {
return _buildTvAdjustTile(
s: s,
focusNode: _tampilanFocusNode(rowIndex),
label: label,
valueLabel: value ? trueLabel : falseLabel,
progress: value ? 1 : 0,
helperText:
'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk mengganti status.',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: rowIndex > 0 ? () => _focusTampilanRow(rowIndex - 1) : null,
onMoveDown: rowIndex + 1 < _tampilanRowCount()
? () => _focusTampilanRow(rowIndex + 1)
: null,
onIncrement: () {
if (!value) onChanged(true);
},
onDecrement: () {
if (value) onChanged(false);
},
);
}
Widget _buildTvPrimaryActionSurface({
required double s,
required Widget icon,
required String label,
required bool isFocused,
}) {
final backgroundColor =
isFocused ? SacredColors.primary : SacredColors.secondary;
final foregroundColor = SacredColors.onPrimary;
return AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 28 * s),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(SacredRadii.lg),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconTheme(
data: IconThemeData(color: foregroundColor, size: 24 * s),
child: DefaultTextStyle(
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.bold,
color: foregroundColor,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
icon,
SizedBox(width: 16 * s),
Text(label),
],
),
),
),
],
),
);
}
Widget _sectionLabel(String label, double s) {
return Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
);
}
Widget _buildIdentityTab(double s) {
final nameRow = 0;
final addressRow = 1;
final searchRow = 2;
return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) => _handleSimpleTabKey(event),
child: SingleChildScrollView(
controller: _identityScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Identitas & Lokasi Masjid',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 48 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField(
'Nama Masjid',
_masjidNameCtrl,
s,
focusNode: _identityFocusNodes[nameRow],
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveDown: () => _focusIdentityRow(addressRow),
),
SizedBox(height: 32 * s),
_buildTextField(
'Alamat Lengkap',
_masjidAddressCtrl,
s,
maxLines: 2,
focusNode: _identityFocusNodes[addressRow],
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusIdentityRow(nameRow),
onMoveDown: () => _focusIdentityRow(searchRow),
),
SizedBox(height: 32 * s),
Text(
'Lokasi Jadwal Shalat (MyQuran API)',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 12 * s),
_buildReadonlyField(
_cityCtrl,
s,
focusable: false,
),
SizedBox(height: 16 * s),
_scrollAware(
controller: _identityScrollController,
child: Focus(
focusNode: _identityFocusNodes[searchRow],
onKeyEvent: (node, event) => _handleIdentityActionKey(
searchRow,
event,
onActivate: () => _showCitySearchDialog(s),
),
child: ListenableBuilder(
listenable: _identityFocusNodes[searchRow],
builder: (context, _) {
final isFocused =
_identityFocusNodes[searchRow].hasFocus;
return AnimatedScale(
scale: isFocused ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
decoration: BoxDecoration(
color: isFocused
? SacredColors.surfaceContainerLow
.withValues(alpha: 0.96)
: Colors.transparent,
borderRadius:
BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: isFocused
? SacredColors.primary
.withValues(alpha: 0.95)
: Colors.transparent,
width: isFocused ? 3 : 0,
),
boxShadow: isFocused
? [
BoxShadow(
color: SacredColors.primary
.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: ElevatedButton.icon(
onPressed: () => _showCitySearchDialog(s),
icon: HugeIcon(
icon: HugeIcons.strokeRoundedSearch01,
color: isFocused
? SacredColors.onPrimary
: SacredColors.onPrimary,
),
label: Text(
'CARI KOTA',
style: TextStyle(fontSize: 16 * s),
),
style: _tvElevatedActionStyle(
s: s,
normalBackground: SacredColors.secondary,
normalForeground: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(
horizontal: 24 * s,
vertical: 24 * s,
),
fontSize: 16 * s,
),
),
),
);
},
),
),
),
],
),
),
],
),
),
),
);
}
Widget _buildJadwalTab(double s) {
final settings = ref.watch(settingsProvider);
final todayScheduleOption = ref.watch(todayScheduleProvider);
final cacheStatus = ref.watch(scheduleCacheStatusProvider);
final displayedHijri = ref.watch(hijriDateProvider).valueOrNull;
final cacheRangeLabel = cacheStatus.hasData
? '${_formatCacheDate(cacheStatus.startDate)} - ${_formatCacheDate(cacheStatus.endDate)}'
: 'Belum ada data';
return SingleChildScrollView(
controller: _jadwalScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jadwal & Sinkronisasi',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 48 * s),
// Sync Card
Container(
width: double.infinity,
padding: EdgeInsets.all(40 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.4)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status Data Jadwal',
style: GoogleFonts.manrope(fontSize: 20 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurfaceVariant, letterSpacing: 1 * s),
),
SizedBox(height: 24 * s),
Wrap(
spacing: 48 * s,
runSpacing: 20 * s,
children: [
_buildStatusRow('Terakhir Sync', settings.lastSyncDate ?? 'Belum pernah', HugeIcons.strokeRoundedClock01, s),
_buildStatusRow('Sumber Data', 'api.myquran.com', HugeIcons.strokeRoundedDatabase01, s),
_buildStatusRow('Lokasi Data', settings.cityDisplayName, HugeIcons.strokeRoundedLocation01, s),
_buildStatusRow('Cache Tersimpan', cacheRangeLabel, HugeIcons.strokeRoundedCalendar03, s),
_buildStatusRow('Jumlah Hari', cacheStatus.hasData ? '${cacheStatus.cachedDays} hari' : '0 hari', HugeIcons.strokeRoundedTaskDaily01, s),
_buildStatusRow('Status Update', _buildCacheUpdateLabel(cacheStatus, todayScheduleOption != null), HugeIcons.strokeRoundedAlert02, s),
],
),
],
),
),
],
),
),
SizedBox(height: 20 * s),
_buildJadwalActionButton(
rowIndex: 0,
s: s,
onActivate: _isSyncing ? () {} : _syncData,
builder: (isFocused) => _buildTvPrimaryActionSurface(
s: s,
isFocused: isFocused,
icon: _isSyncing
? SizedBox(
width: 24 * s,
height: 24 * s,
child: CircularProgressIndicator(
color: isFocused
? SacredColors.onPrimary
: SacredColors.onSecondary,
strokeWidth: 3,
),
)
: HugeIcon(
icon: HugeIcons.strokeRoundedCloudDownload,
color: isFocused
? SacredColors.onPrimary
: SacredColors.onSecondary,
),
label: _isSyncing
? 'MENYINKRONKAN...'
: 'SINKRONKAN DATA BULAN INI',
),
),
SizedBox(height: 64 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Kalender Hijriah', s),
SizedBox(height: 8 * s),
Text(
'Sesuaikan tampilan tanggal Hijriah jika hasil rukyat lokal masjid berbeda dari nilai default API.',
style: GoogleFonts.manrope(
fontSize: 14 * s,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 24 * s),
Container(
width: double.infinity,
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tanggal tampil saat ini',
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 8 * s),
Text(
displayedHijri ?? 'Memuat tanggal Hijriah...',
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 16 * s,
vertical: 10 * s,
),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(SacredRadii.full),
),
child: Text(
'Offset ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari',
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
),
],
),
),
SizedBox(height: 20 * s),
_buildHijriOffsetControl(
s,
focusNode: _jadwalFocusNodes[1],
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(0),
onMoveDown: () => _focusJadwalRow(2),
),
SizedBox(height: 16 * s),
_buildJadwalActionButton(
rowIndex: 2,
s: s,
onActivate: () {
setState(() {
_hijriOffsetDays = 0;
});
_queueJadwalAutoSave(
message: 'Offset Hijriah direset dan otomatis tersimpan',
);
},
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_hijriOffsetDays = 0;
});
_queueJadwalAutoSave(
message: 'Offset Hijriah direset dan otomatis tersimpan',
);
},
icon: const Icon(Icons.refresh),
label: const Text('RESET OFFSET'),
),
),
],
),
),
SizedBox(height: 64 * s),
// Waktu & Durasi Card
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Waktu & Durasi', s),
SizedBox(height: 8 * s),
Text(
'Seluruh pengaturan angka utama untuk alur jadwal ditangani dengan stepper agar nyaman dipakai dengan remote Android TV.',
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 32 * s),
_buildTvIntStepperField(
s: s,
label: 'Pra-Adzan',
focusNode: _jadwalFocusNodes[3],
controller: _preAdzanLeadCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(2),
onMoveDown: () => _focusJadwalRow(4),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Blank Screen Normal',
focusNode: _jadwalFocusNodes[4],
controller: _blankNormalCtrl,
fallback: 15,
min: 0,
max: 120,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(3),
onMoveDown: () => _focusJadwalRow(5),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Blank Screen Jumat',
focusNode: _jadwalFocusNodes[5],
controller: _blankJumatCtrl,
fallback: 45,
min: 0,
max: 180,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(4),
onMoveDown: () => _focusJadwalRow(6),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 28 * s),
Text(
'Jeda Waktu Iqamah (Menit)',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 8 * s),
Text(
'Tentukan durasi hitung mundur dari selesai Adzan hingga iqamah untuk tiap shalat fardhu.',
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 24 * s),
_buildTvIntStepperField(
s: s,
label: 'Iqamah Subuh',
focusNode: _jadwalFocusNodes[6],
controller: _iqomahSubuhCtrl,
fallback: 15,
min: 0,
max: 60,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(5),
onMoveDown: () => _focusJadwalRow(7),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Iqamah Dzuhur',
focusNode: _jadwalFocusNodes[7],
controller: _iqomahDzuhurCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(6),
onMoveDown: () => _focusJadwalRow(8),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Iqamah Ashar',
focusNode: _jadwalFocusNodes[8],
controller: _iqomahAsharCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(7),
onMoveDown: () => _focusJadwalRow(9),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Iqamah Maghrib',
focusNode: _jadwalFocusNodes[9],
controller: _iqomahMaghribCtrl,
fallback: 7,
min: 0,
max: 60,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(8),
onMoveDown: () => _focusJadwalRow(10),
onValueChanged: _queueJadwalAutoSave,
),
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Iqamah Isya',
focusNode: _jadwalFocusNodes[10],
controller: _iqomahIsyaCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusJadwalRow(9),
onValueChanged: _queueJadwalAutoSave,
),
],
)),
SizedBox(height: 64 * s),
Text(
'Pratinjau Jadwal Hari Ini',
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 32 * s),
// Schedule Grid
Builder(
builder: (context) {
if (todayScheduleOption == null) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 24 * s),
child: Center(
child: Text('Data jadwal kosong. Silakan lakukan sinkronisasi.', style: GoogleFonts.manrope(fontSize: 24 * s, color: SacredColors.error)),
),
);
}
final prayerMap = {
'IMSAK': todayScheduleOption.imsak,
'SUBUH': todayScheduleOption.subuh,
'TERBIT': todayScheduleOption.terbit,
'DHUHA': todayScheduleOption.dhuha,
'DZUHUR': todayScheduleOption.dzuhur,
'ASHAR': todayScheduleOption.ashar,
'MAGHRIB': todayScheduleOption.maghrib,
'ISYA': todayScheduleOption.isya,
};
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 24 * s,
mainAxisSpacing: 24 * s,
childAspectRatio: 2.2, // wide rectangular Google Stitch cards
),
itemCount: prayerMap.length,
itemBuilder: (context, index) {
final key = prayerMap.keys.elementAt(index);
final time = prayerMap[key]!;
return _buildPrayerCard(key, time, s);
},
);
},
),
SizedBox(height: 32 * s),
],
),
);
}
Widget _buildHijriOffsetControl(
double s, {
FocusNode? focusNode,
VoidCallback? onMoveLeft,
VoidCallback? onMoveUp,
VoidCallback? onMoveDown,
}) {
const minOffset = -3;
const maxOffset = 3;
return _buildTvAdjustTile(
s: s,
focusNode: focusNode,
label: 'Offset Hari Hijriah',
valueLabel: '${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari',
progress: (_hijriOffsetDays - minOffset) / (maxOffset - minOffset),
helperText: 'Tekan OK untuk masuk mode ubah. Saat aktif, gunakan ← → untuk geser 1 hari.',
onMoveLeft: onMoveLeft,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
onIncrement: () {
setState(() {
_hijriOffsetDays = (_hijriOffsetDays + 1).clamp(minOffset, maxOffset);
});
_queueJadwalAutoSave(
message:
'Offset Hijriah ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari tersimpan',
);
},
onDecrement: () {
setState(() {
_hijriOffsetDays = (_hijriOffsetDays - 1).clamp(minOffset, maxOffset);
});
_queueJadwalAutoSave(
message:
'Offset Hijriah ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari tersimpan',
);
},
);
}
Widget _buildPrayerCard(String name, String time, double s) {
return Container(
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10 * s,
offset: Offset(0, 4 * s),
)
]
),
padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 24 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name,
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
SizedBox(height: 8 * s),
Text(
time,
style: GoogleFonts.plusJakartaSans(
fontSize: 42 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
],
),
);
}
ScrollController _scrollControllerForTab(int tabIndex) {
switch (tabIndex) {
case 0:
return _identityScrollController;
case 1:
return _jadwalScrollController;
case 2:
return _tampilanScrollController;
case 3:
return _jumatScrollController;
case 4:
default:
return _simulasiScrollController;
}
}
Widget _scrollAware({
required ScrollController controller,
required Widget child,
}) {
return Builder(
builder: (context) {
return Focus(
onFocusChange: (hasFocus) {
if (!hasFocus || !controller.hasClients) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
Scrollable.ensureVisible(
context,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: 0.18,
);
}
});
},
child: child,
);
},
);
}
int _parseCtrlInt(TextEditingController ctrl, int fallback) {
return int.tryParse(ctrl.text.trim()) ?? fallback;
}
void _bumpCtrlInt(
TextEditingController ctrl, {
required int delta,
required int min,
required int max,
required int fallback,
}) {
final next = (_parseCtrlInt(ctrl, fallback) + delta).clamp(min, max);
setState(() {
ctrl.text = next.toString();
});
}
Widget _buildTvIntStepperField({
required double s,
required String label,
FocusNode? focusNode,
required TextEditingController controller,
required int fallback,
required int min,
required int max,
String suffix = '',
VoidCallback? onMoveLeft,
VoidCallback? onMoveUp,
VoidCallback? onMoveDown,
VoidCallback? onValueChanged,
}) {
final value = _parseCtrlInt(controller, fallback);
final valueLabel = suffix.isEmpty ? '$value' : '$value $suffix';
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: _buildTvAdjustTile(
s: s,
focusNode: focusNode,
label: label,
valueLabel: valueLabel,
progress: ((value - min) / (max - min)).clamp(0.0, 1.0),
helperText: 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk menyesuaikan nilai.',
onMoveLeft: onMoveLeft,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
onIncrement: () {
_bumpCtrlInt(
controller,
delta: 1,
min: min,
max: max,
fallback: fallback,
);
onValueChanged?.call();
},
onDecrement: () {
_bumpCtrlInt(
controller,
delta: -1,
min: min,
max: max,
fallback: fallback,
);
onValueChanged?.call();
},
),
);
}
Widget _buildTvAdjustTile({
required double s,
FocusNode? focusNode,
required String label,
required String valueLabel,
required double progress,
required String helperText,
VoidCallback? onMoveLeft,
VoidCallback? onMoveUp,
VoidCallback? onMoveDown,
required VoidCallback onIncrement,
required VoidCallback onDecrement,
}) {
return _TvAdjustTile(
scale: s,
focusNode: focusNode,
label: label,
valueLabel: valueLabel,
progress: progress,
helperText: helperText,
onMoveLeft: onMoveLeft,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
onIncrement: onIncrement,
onDecrement: onDecrement,
);
}
Widget _buildTextField(
String label,
TextEditingController ctrl,
double s, {
int maxLines = 1,
FocusNode? focusNode,
TextInputType? keyboardType,
ValueChanged<String>? 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<double> onChanged,
FocusNode? focusNode,
VoidCallback? onMoveLeft,
VoidCallback? onMoveUp,
VoidCallback? onMoveDown,
}) {
final pct = (value * 100).round();
const step = 0.05;
return _buildTvAdjustTile(
s: s,
focusNode: focusNode,
label: label,
valueLabel: '$pct%',
progress: ((value - 0.5) / 1.5).clamp(0.0, 1.0),
helperText: 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk mengubah skala 5%.',
onMoveLeft: onMoveLeft,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
onIncrement: () => onChanged((value + step).clamp(0.5, 2.0)),
onDecrement: () => onChanged((value - step).clamp(0.5, 2.0)),
);
}
Widget _buildSimulasiTab(double s) {
final simulationOffset = ref.watch(mockTimeOffsetProvider);
final isSimulating = simulationOffset != Duration.zero;
final simulatedMinutes = simulationOffset.inMinutes;
return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) => _handleSimpleTabKey(event),
child: SingleChildScrollView(
controller: _simulasiScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode Simulasi Pengembang',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 16 * s),
Text(
'Gunakan tombol di bawah ini untuk melihat pratinjau bagaimana aplikasi bereaksi terhadap berbagai waktu dan status tanpa harus menunggu waktu sebenarnya.\nFitur ini bekerja dengan menggeser waktu aplikasi (Time Travel).',
style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 48 * s),
Container(
width: double.infinity,
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: isSimulating
? SacredColors.error.withValues(alpha: 0.12)
: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: isSimulating
? SacredColors.error.withValues(alpha: 0.45)
: SacredColors.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: [
HugeIcon(
icon: isSimulating
? HugeIcons.strokeRoundedAlert02
: HugeIcons.strokeRoundedCheckmarkCircle02,
color: isSimulating
? SacredColors.error
: SacredColors.primary,
size: 28 * s,
),
SizedBox(width: 16 * s),
Expanded(
child: Text(
isSimulating
? 'Simulasi aktif (${simulatedMinutes >= 0 ? '+' : ''}$simulatedMinutes menit). Gunakan kartu pertama untuk keluar dan kembali ke waktu asli.'
: 'Simulasi tidak aktif. Pilih salah satu skenario di bawah untuk mulai menguji tampilan layar.',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
),
],
),
),
SizedBox(height: 24 * s),
_simulasiCard(
s: s,
title: isSimulating ? 'Keluar dari Simulasi' : 'Gunakan Waktu Asli',
icon: isSimulating
? HugeIcons.strokeRoundedCancelCircle
: HugeIcons.strokeRoundedHome01,
desc: isSimulating
? 'Matikan mode simulasi dan kembali ke waktu sistem saat ini.'
: 'Pastikan aplikasi berjalan menggunakan waktu asli perangkat.',
onTap: () => _activateSimulation(
() => _simulateTimeOffset(Duration.zero),
),
focusNode: _simulasiFocusNodes[0],
rowIndex: 0,
),
SizedBox(height: 16 * s),
_simulasiCard(
s: s,
title: 'Menuju Adzan',
icon: HugeIcons.strokeRoundedClock01,
desc: 'Melompat ke 2 menit sebelum Adzan Dzuhur hari ini.',
onTap: () => _activateSimulation(
() => _simulateEvent('pre_adzan'),
),
focusNode: _simulasiFocusNodes[1],
rowIndex: 1,
),
SizedBox(height: 16 * s),
_simulasiCard(
s: s,
title: 'Selama Adzan',
icon: HugeIcons.strokeRoundedMegaphone01,
desc: 'Melompat ke tepat waktu Adzan Dzuhur berkumandang.',
onTap: () => _activateSimulation(
() => _simulateEvent('adzan'),
),
focusNode: _simulasiFocusNodes[2],
rowIndex: 2,
),
SizedBox(height: 16 * s),
_simulasiCard(
s: s,
title: 'Menuju Iqomah',
icon: HugeIcons.strokeRoundedTimer02,
desc: 'Melompat ke saat waktu iqomah sedang menghitung mundur (1 menit setelah Adzan).',
onTap: () => _activateSimulation(
() => _simulateEvent('iqomah'),
),
focusNode: _simulasiFocusNodes[3],
rowIndex: 3,
),
SizedBox(height: 16 * s),
_simulasiCard(
s: s,
title: 'Persiapan Jumat',
icon: HugeIcons.strokeRoundedCalendar03,
desc: 'Menyimulasikan layar khusus persiapan Jumat (30 menit sebelum Adzan Dzuhur).',
onTap: () => _activateSimulation(
() => _simulateEvent('jumat_incoming'),
),
focusNode: _simulasiFocusNodes[4],
rowIndex: 4,
),
SizedBox(height: 16 * s),
_simulasiCard(
s: s,
title: 'Khutbah Berlangsung',
icon: HugeIcons.strokeRoundedUserGroup,
desc: 'Menyimulasikan layar saat Khutbah sedang berlangsung tanpa hitungan mundur (2 menit setelah Adzan Dzuhur).',
onTap: () => _activateSimulation(
() => _simulateEvent('jumat_khutbah'),
),
focusNode: _simulasiFocusNodes[5],
rowIndex: 5,
),
SizedBox(height: 16 * s),
_simulasiCard(
s: s,
title: 'Mode Shalat',
icon: HugeIcons.strokeRoundedMoon02,
desc: 'Layar menjadi hitam atau gelap selama shalat berlangsung.',
onTap: () => _activateSimulation(
() => _simulateEvent('shalat'),
),
focusNode: _simulasiFocusNodes[6],
rowIndex: 6,
),
],
),
),
),
);
}
Widget _simulasiCard({
required double s,
required String title,
required dynamic icon,
required String desc,
required VoidCallback onTap,
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':
targetTime = dzuhurTime.subtract(const Duration(minutes: 2));
break;
case 'adzan':
targetTime = dzuhurTime;
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);
}
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<bool>? onFocusChange;
final FocusOnKeyEventCallback? onKeyEvent;
final VoidCallback onTap;
const _NavButton({
required this.title,
required this.icon,
required this.isActive,
required this.scale,
this.focusNode,
this.onFocusChange,
this.onKeyEvent,
required this.onTap,
});
@override
State<_NavButton> createState() => _NavButtonState();
}
class _TvAdjustTile extends StatefulWidget {
final double scale;
final FocusNode? focusNode;
final String label;
final String valueLabel;
final double progress;
final String helperText;
final VoidCallback? onMoveLeft;
final VoidCallback? onMoveUp;
final VoidCallback? onMoveDown;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
const _TvAdjustTile({
required this.scale,
this.focusNode,
required this.label,
required this.valueLabel,
required this.progress,
required this.helperText,
this.onMoveLeft,
this.onMoveUp,
this.onMoveDown,
required this.onIncrement,
required this.onDecrement,
});
@override
State<_TvAdjustTile> createState() => _TvAdjustTileState();
}
class _TvEditableTextTile extends StatefulWidget {
final double scale;
final String label;
final TextEditingController controller;
final FocusNode? focusNode;
final int maxLines;
final TextInputType? keyboardType;
final ValueChanged<String>? onChanged;
final VoidCallback? onEditComplete;
final VoidCallback? onMoveLeft;
final VoidCallback? onMoveUp;
final VoidCallback? onMoveDown;
const _TvEditableTextTile({
required this.scale,
required this.label,
required this.controller,
this.focusNode,
this.maxLines = 1,
this.keyboardType,
this.onChanged,
this.onEditComplete,
this.onMoveLeft,
this.onMoveUp,
this.onMoveDown,
});
@override
State<_TvEditableTextTile> createState() => _TvEditableTextTileState();
}
class _TvAdjustTileState extends State<_TvAdjustTile> {
late final FocusNode _fallbackFocusNode =
FocusNode(debugLabel: 'tv_adjust_tile');
bool _isFocused = false;
bool _isEditing = false;
FocusNode get _focusNode => widget.focusNode ?? _fallbackFocusNode;
@override
void dispose() {
if (widget.focusNode == null) {
_fallbackFocusNode.dispose();
}
super.dispose();
}
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
setState(() => _isEditing = !_isEditing);
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowUp) {
widget.onMoveUp?.call();
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowDown) {
widget.onMoveDown?.call();
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) {
widget.onMoveLeft?.call();
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
if (!_isEditing) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.arrowLeft) {
widget.onDecrement();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
widget.onIncrement();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.escape) {
setState(() => _isEditing = false);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
final highlight = _isFocused || _isEditing;
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKey,
onFocusChange: (value) {
setState(() {
_isFocused = value;
if (!value) _isEditing = false;
});
},
child: InkWell(
onTap: _focusNode.requestFocus,
borderRadius: BorderRadius.circular(SacredRadii.md),
child: AnimatedScale(
scale: highlight ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(18 * s),
decoration: BoxDecoration(
color: highlight
? SacredColors.surfaceContainerLow
: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(
color: highlight
? SacredColors.primary.withValues(alpha: 0.95)
: SacredColors.outlineVariant.withValues(alpha: 0.25),
width: highlight ? 3 : 1,
),
boxShadow: highlight
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
widget.label,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
),
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 14 * s,
vertical: 6 * s,
),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(SacredRadii.sm),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.35),
),
),
child: Text(
widget.valueLabel,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
),
],
),
SizedBox(height: 14 * s),
Row(
children: [
Container(
width: 36 * s,
height: 36 * s,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _isEditing
? SacredColors.surfaceContainerHigh
: SacredColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(SacredRadii.sm),
border: Border.all(
color: _isEditing
? SacredColors.primary.withValues(alpha: 0.8)
: SacredColors.outlineVariant.withValues(alpha: 0.35),
),
),
child: Text(
'',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
),
),
),
SizedBox(width: 12 * s),
Expanded(
child: Container(
height: 6 * s,
decoration: BoxDecoration(
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(3 * s),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: widget.progress.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: SacredColors.primary,
borderRadius: BorderRadius.circular(3 * s),
),
),
),
),
),
SizedBox(width: 12 * s),
Container(
width: 36 * s,
height: 36 * s,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _isEditing
? SacredColors.surfaceContainerHigh
: SacredColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(SacredRadii.sm),
border: Border.all(
color: _isEditing
? SacredColors.primary.withValues(alpha: 0.8)
: SacredColors.outlineVariant.withValues(alpha: 0.35),
),
),
child: Text(
'',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
),
),
),
],
),
SizedBox(height: 10 * s),
Text(
_isEditing
? 'Mode ubah aktif. Gunakan ← → lalu tekan OK untuk selesai.'
: widget.helperText,
style: GoogleFonts.manrope(
fontSize: 11 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75),
),
),
],
),
),
),
),
);
}
}
class _TvEditableTextTileState extends State<_TvEditableTextTile> {
late final FocusNode _fallbackFocusNode =
FocusNode(debugLabel: 'tv_edit_tile');
late final FocusNode _textFocusNode =
FocusNode(debugLabel: 'tv_edit_text');
bool _isFocused = false;
bool _isEditing = false;
FocusNode get _outerFocusNode => widget.focusNode ?? _fallbackFocusNode;
@override
void initState() {
super.initState();
_textFocusNode.canRequestFocus = false;
_textFocusNode.addListener(_handleTextFocusChange);
}
@override
void dispose() {
_textFocusNode.removeListener(_handleTextFocusChange);
_textFocusNode.dispose();
if (widget.focusNode == null) {
_fallbackFocusNode.dispose();
}
super.dispose();
}
void _handleTextFocusChange() {
if (!mounted) return;
if (!_textFocusNode.hasFocus && _isEditing) {
_finishEditing();
return;
}
setState(() {});
}
void _startEditing() {
setState(() {
_isEditing = true;
_textFocusNode.canRequestFocus = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_textFocusNode.requestFocus();
}
});
}
void _finishEditing() {
if (!mounted) return;
setState(() {
_isEditing = false;
_textFocusNode.canRequestFocus = false;
});
widget.onEditComplete?.call();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_outerFocusNode.requestFocus();
}
});
}
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (!_isEditing &&
(key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select)) {
_startEditing();
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) {
widget.onMoveLeft?.call();
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowUp) {
widget.onMoveUp?.call();
return KeyEventResult.handled;
}
if (!_isEditing && key == LogicalKeyboardKey.arrowDown) {
widget.onMoveDown?.call();
return KeyEventResult.handled;
}
if (_isEditing && key == LogicalKeyboardKey.escape) {
_finishEditing();
return KeyEventResult.handled;
}
if (_isEditing &&
widget.maxLines == 1 &&
(key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select)) {
_finishEditing();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
final highlight = _isFocused || _isEditing || _textFocusNode.hasFocus;
return Focus(
focusNode: _outerFocusNode,
onKeyEvent: _handleKey,
onFocusChange: (value) {
setState(() {
_isFocused = value;
});
},
child: InkWell(
onTap: () {
if (_isEditing) {
_textFocusNode.requestFocus();
} else {
_startEditing();
}
},
borderRadius: BorderRadius.circular(SacredRadii.md),
child: AnimatedScale(
scale: highlight ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(18 * s),
decoration: BoxDecoration(
color: highlight
? SacredColors.surfaceContainerLow
: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(
color: highlight
? SacredColors.primary.withValues(alpha: 0.95)
: SacredColors.outlineVariant.withValues(alpha: 0.35),
width: highlight ? 3 : 1,
),
boxShadow: highlight
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.label,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 12 * s),
AbsorbPointer(
absorbing: !_isEditing,
child: TextField(
focusNode: _textFocusNode,
controller: widget.controller,
maxLines: widget.maxLines,
keyboardType: widget.keyboardType,
readOnly: !_isEditing,
showCursor: _isEditing,
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
color: SacredColors.onSurface,
),
decoration: InputDecoration(
filled: true,
fillColor: SacredColors.surfaceContainerLowest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(SacredRadii.md),
borderSide: BorderSide(
color: SacredColors.outlineVariant.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(SacredRadii.md),
borderSide: const BorderSide(
color: SacredColors.primary,
width: 2,
),
),
),
onChanged: widget.onChanged,
onSubmitted: (_) {
if (widget.maxLines == 1) {
_finishEditing();
}
},
),
),
SizedBox(height: 8 * s),
Text(
_isEditing
? 'Mode edit aktif. Tekan ESC untuk selesai.'
: 'Tekan OK untuk mulai edit.',
style: GoogleFonts.manrope(
fontSize: 11 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75),
),
),
],
),
),
),
),
);
}
}
class _TvFocusFrame extends StatefulWidget {
final Widget child;
final double scale;
final BorderRadius borderRadius;
const _TvFocusFrame({
required this.child,
required this.scale,
required this.borderRadius,
});
@override
State<_TvFocusFrame> createState() => _TvFocusFrameState();
}
class _TvFocusFrameState extends State<_TvFocusFrame> {
bool _hasFocus = false;
@override
Widget build(BuildContext context) {
final s = widget.scale;
return Focus(
canRequestFocus: false,
descendantsAreFocusable: true,
onFocusChange: (value) {
if (_hasFocus != value) {
setState(() => _hasFocus = value);
}
},
child: AnimatedScale(
scale: _hasFocus ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(_hasFocus ? 5 * s : 0),
decoration: BoxDecoration(
color: _hasFocus
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
: Colors.transparent,
borderRadius: widget.borderRadius,
border: Border.all(
color: _hasFocus
? SacredColors.primary.withValues(alpha: 0.95)
: Colors.transparent,
width: _hasFocus ? 3 : 0,
),
boxShadow: _hasFocus
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: widget.child,
),
),
);
}
}
class _NavButtonState extends State<_NavButton> {
bool _isFocused = false;
@override
Widget build(BuildContext context) {
final s = widget.scale;
final highlight = widget.isActive || _isFocused;
return Focus(
focusNode: widget.focusNode,
onFocusChange: widget.onFocusChange,
onKeyEvent: widget.onKeyEvent,
child: FocusableActionDetector(
onShowFocusHighlight: (value) => setState(() => _isFocused = value),
child: InkWell(
onTap: widget.onTap,
focusColor: SacredColors.primary.withValues(alpha: 0.22),
hoverColor: SacredColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(SacredRadii.lg),
child: AnimatedScale(
scale: highlight ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s),
decoration: BoxDecoration(
color: highlight
? SacredColors.surfaceContainerLow
: Colors.transparent,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: highlight
? Border.all(
color: SacredColors.primary.withValues(alpha: 0.95),
width: 3,
)
: null,
boxShadow: highlight
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.24),
blurRadius: 22 * s,
spreadRadius: 1 * s,
),
]
: null,
),
child: Row(
children: [
HugeIcon(
icon: widget.icon,
color: highlight
? SacredColors.onSurface
: SacredColors.onSurfaceVariant,
size: 28 * s,
),
SizedBox(width: 20 * s),
Expanded(
child: Text(
widget.title,
style: GoogleFonts.plusJakartaSans(
fontSize: 18 * s,
fontWeight: FontWeight.bold,
color: highlight
? SacredColors.onSurface
: SacredColors.onSurfaceVariant,
letterSpacing: 1 * s,
),
),
),
],
),
),
),
),
),
);
}
}