5697 lines
215 KiB
Dart
5697 lines
215 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 '../../data/services/sound_service.dart';
|
|
import '../../data/services/update_service.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'dart:io';
|
|
|
|
class AdminScreen extends ConsumerStatefulWidget {
|
|
final int initialTab;
|
|
final bool focusSelectedTabOnOpen;
|
|
|
|
const AdminScreen({
|
|
super.key,
|
|
this.initialTab = 0,
|
|
this.focusSelectedTabOnOpen = false,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<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();
|
|
final _mainHeroDurCtrl = TextEditingController();
|
|
final _textSlideDurCtrl = 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> _textSlides = [];
|
|
List<String> _runningTexts = [];
|
|
List<int> _runningTextDurations = [];
|
|
|
|
// Granular text group scales
|
|
double _scaleCardLabel = 1.0;
|
|
double _scaleCardBody = 1.0;
|
|
double _scaleRunningText = 1.0;
|
|
double _scaleTopHeader = 1.0;
|
|
double _scaleTextSlideCenter = 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 _pengumumanScrollController = ScrollController();
|
|
final _jumatScrollController = ScrollController();
|
|
final _simulasiScrollController = ScrollController();
|
|
final _tentangScrollController = ScrollController();
|
|
late final FocusNode _identityEntryFocusNode;
|
|
late final FocusNode _tampilanEntryFocusNode;
|
|
late final FocusNode _pengumumanEntryFocusNode;
|
|
late final FocusNode _jumatEntryFocusNode;
|
|
late final FocusNode _simulasiEntryFocusNode;
|
|
late final FocusNode _tentangEntryFocusNode;
|
|
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;
|
|
late final List<FocusNode> _tentangFocusNodes;
|
|
final Map<int, FocusNode> _tampilanFocusNodes = {};
|
|
final Map<int, FocusNode> _pengumumanFocusNodes = {};
|
|
Timer? _identityAutoSaveTimer;
|
|
Timer? _tampilanAutoSaveTimer;
|
|
Timer? _pengumumanAutoSaveTimer;
|
|
Timer? _jumatAutoSaveTimer;
|
|
Timer? _jadwalAutoSaveTimer;
|
|
Timer? _statusBadgeTimer;
|
|
String? _statusBadgeMessage;
|
|
bool _statusBadgeIsError = false;
|
|
int _hijriOffsetDays = 0;
|
|
AppVersionInfo? _currentVersion;
|
|
UpdateCheckResult? _updateCheckResult;
|
|
bool _isCheckingUpdate = false;
|
|
bool _isInstallingUpdate = false;
|
|
double _updateDownloadProgress = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedTab = widget.initialTab.clamp(0, 6);
|
|
_identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry');
|
|
_tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry');
|
|
_pengumumanEntryFocusNode = FocusNode(debugLabel: 'pengumuman_entry');
|
|
_jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry');
|
|
_simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry');
|
|
_tentangEntryFocusNode = FocusNode(debugLabel: 'tentang_entry');
|
|
_navFocusNodes = List.generate(
|
|
7,
|
|
(index) => FocusNode(debugLabel: 'admin_nav_$index'),
|
|
);
|
|
_identityFocusNodes = [
|
|
_identityEntryFocusNode,
|
|
...List.generate(
|
|
2,
|
|
(index) => FocusNode(debugLabel: 'identity_row_${index + 1}'),
|
|
),
|
|
];
|
|
_jumatFocusNodes = [
|
|
_jumatEntryFocusNode,
|
|
FocusNode(debugLabel: 'jumat_row_1'),
|
|
];
|
|
_simulasiFocusNodes = [
|
|
_simulasiEntryFocusNode,
|
|
...List.generate(
|
|
8,
|
|
(index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'),
|
|
),
|
|
];
|
|
_tentangFocusNodes = [
|
|
_tentangEntryFocusNode,
|
|
FocusNode(debugLabel: 'tentang_row_1'),
|
|
];
|
|
_jadwalFocusNodes = List.generate(
|
|
11,
|
|
(index) => FocusNode(debugLabel: 'jadwal_row_$index'),
|
|
);
|
|
final settings = ref.read(settingsProvider);
|
|
_masjidNameCtrl.text = settings.masjidName;
|
|
_masjidAddressCtrl.text = settings.masjidAddress;
|
|
_cityCtrl.text = '${settings.cityDisplayName} (${settings.cityIdApi})';
|
|
_mainDurCtrl.text = settings.mainScreenDurationSec.toString();
|
|
_slideDurCtrl.text = settings.slideDurationSec.toString();
|
|
_mainHeroDurCtrl.text = settings.mainCenterSlideDurationSec.toString();
|
|
_textSlideDurCtrl.text = settings.announcementSlideDurationSec.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;
|
|
_textSlides = List.from(settings.textSlides);
|
|
_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;
|
|
_scaleTopHeader = settings.scaleTopHeader;
|
|
_scaleTextSlideCenter = settings.scaleTextSlideCenter;
|
|
_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);
|
|
_mainHeroDurCtrl.addListener(_queuePengumumanAutoSave);
|
|
_textSlideDurCtrl.addListener(_queuePengumumanAutoSave);
|
|
_unsplashKeywordCtrl.addListener(_queueTampilanAutoSave);
|
|
_unsplashRotationCtrl.addListener(_queueTampilanAutoSave);
|
|
_masjidNameCtrl.addListener(_queueIdentityAutoSave);
|
|
_masjidAddressCtrl.addListener(_queueIdentityAutoSave);
|
|
_khatibCtrl.addListener(() {
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
_queueJumatAutoSave();
|
|
});
|
|
_imamCtrl.addListener(() {
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
_queueJumatAutoSave();
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (widget.focusSelectedTabOnOpen) {
|
|
_focusEntryForTab(_selectedTab);
|
|
} else {
|
|
_focusNavTab(_selectedTab);
|
|
}
|
|
});
|
|
unawaited(_loadCurrentVersion());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_masjidNameCtrl.dispose();
|
|
_masjidAddressCtrl.dispose();
|
|
_cityCtrl.dispose();
|
|
_mainDurCtrl.dispose();
|
|
_slideDurCtrl.dispose();
|
|
_mainHeroDurCtrl.dispose();
|
|
_textSlideDurCtrl.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();
|
|
_pengumumanScrollController.dispose();
|
|
_jumatScrollController.dispose();
|
|
_simulasiScrollController.dispose();
|
|
_tentangScrollController.dispose();
|
|
_tampilanEntryFocusNode.dispose();
|
|
_pengumumanEntryFocusNode.dispose();
|
|
_identityAutoSaveTimer?.cancel();
|
|
_tampilanAutoSaveTimer?.cancel();
|
|
_pengumumanAutoSaveTimer?.cancel();
|
|
_jumatAutoSaveTimer?.cancel();
|
|
_jadwalAutoSaveTimer?.cancel();
|
|
_statusBadgeTimer?.cancel();
|
|
for (final node in _navFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _identityFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _jumatFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _simulasiFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _tentangFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _jadwalFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _tampilanFocusNodes.values) {
|
|
node.dispose();
|
|
}
|
|
for (final node in _pengumumanFocusNodes.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.scaleCardLabel = _scaleCardLabel;
|
|
s.scaleCardBody = _scaleCardBody;
|
|
s.scaleRunningText = _scaleRunningText;
|
|
s.scaleTopHeader = _scaleTopHeader;
|
|
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> _savePengumuman({
|
|
String message = 'Pengaturan pengumuman otomatis tersimpan',
|
|
}) async {
|
|
await ref.read(settingsProvider.notifier).updateSettings((s) {
|
|
s.mainCenterSlideDurationSec =
|
|
int.tryParse(_mainHeroDurCtrl.text.trim()) ?? 10;
|
|
s.announcementSlideDurationSec =
|
|
int.tryParse(_textSlideDurCtrl.text.trim()) ?? 7;
|
|
s.textSlides = List.from(_textSlides);
|
|
s.runningTexts = List.from(_runningTexts);
|
|
s.runningTextDurations = List.from(_runningTextDurations);
|
|
s.marqueeAnimType = _marqueeAnimType;
|
|
s.scaleTextSlideCenter = _scaleTextSlideCenter;
|
|
return s;
|
|
});
|
|
if (mounted) {
|
|
_showStatusBadge(message);
|
|
}
|
|
}
|
|
|
|
void _queuePengumumanAutoSave({
|
|
String message = 'Pengaturan pengumuman otomatis tersimpan',
|
|
}) {
|
|
_pengumumanAutoSaveTimer?.cancel();
|
|
_pengumumanAutoSaveTimer = Timer(
|
|
const Duration(milliseconds: 450),
|
|
() => _savePengumuman(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> _loadCurrentVersion() async {
|
|
final version = await UpdateService.instance.getCurrentVersion();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_currentVersion = version;
|
|
});
|
|
}
|
|
|
|
Future<void> _checkForUpdates() async {
|
|
if (_isCheckingUpdate) return;
|
|
setState(() => _isCheckingUpdate = true);
|
|
final result = await UpdateService.instance.checkForUpdate();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isCheckingUpdate = false;
|
|
_currentVersion = result.current;
|
|
_updateCheckResult = result;
|
|
});
|
|
if (!result.updateAvailable &&
|
|
_selectedTab == 6 &&
|
|
_tentangFocusNodes[1].hasFocus) {
|
|
_focusTentangRow(0);
|
|
}
|
|
_showStatusBadge(
|
|
result.hasError
|
|
? result.errorMessage!
|
|
: result.updateAvailable
|
|
? 'Update baru tersedia'
|
|
: 'Versi ini sudah terbaru',
|
|
isError: result.hasError,
|
|
);
|
|
}
|
|
|
|
Future<void> _installLatestUpdate() async {
|
|
final result = _updateCheckResult;
|
|
final remote = result?.remote;
|
|
if (_isInstallingUpdate || result == null || remote == null) return;
|
|
|
|
if (!result.updateAvailable) {
|
|
_showStatusBadge('Versi ini sudah terbaru');
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isInstallingUpdate = true;
|
|
_updateDownloadProgress = 0;
|
|
});
|
|
|
|
final installResult = await UpdateService.instance.downloadAndTriggerInstall(
|
|
remote,
|
|
onProgress: (progress) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_updateDownloadProgress = progress.clamp(0, 1);
|
|
});
|
|
},
|
|
);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isInstallingUpdate = false;
|
|
});
|
|
_showStatusBadge(installResult.message, isError: !installResult.success);
|
|
}
|
|
|
|
Future<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 {
|
|
bool isActivateKey(LogicalKeyboardKey key) {
|
|
return key == LogicalKeyboardKey.enter ||
|
|
key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.numpadEnter ||
|
|
key == LogicalKeyboardKey.space ||
|
|
key == LogicalKeyboardKey.gameButtonA;
|
|
}
|
|
|
|
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,
|
|
onMoveDown: () {
|
|
searchFocusNode.requestFocus();
|
|
},
|
|
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 (isActivateKey(key)) {
|
|
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 (isActivateKey(key)) {
|
|
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: 'PENGUMUMAN',
|
|
icon: HugeIcons.strokeRoundedNotification03,
|
|
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: 'PENGATURAN JUMAT',
|
|
icon: HugeIcons.strokeRoundedCalendar01,
|
|
isActive: _selectedTab == 4,
|
|
scale: s,
|
|
focusNode: _navFocusNodes[4],
|
|
onFocusChange: (focused) {
|
|
if (focused) _setSelectedTab(4);
|
|
},
|
|
onKeyEvent: (node, event) => _handleNavKey(4, event),
|
|
onTap: () => setState(() => _selectedTab = 4),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_NavButton(
|
|
title: 'SIMULASI',
|
|
icon: HugeIcons.strokeRoundedClock01,
|
|
isActive: _selectedTab == 5,
|
|
scale: s,
|
|
focusNode: _navFocusNodes[5],
|
|
onFocusChange: (focused) {
|
|
if (focused) _setSelectedTab(5);
|
|
},
|
|
onKeyEvent: (node, event) => _handleNavKey(5, event),
|
|
onTap: () => setState(() => _selectedTab = 5),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_NavButton(
|
|
title: 'TENTANG',
|
|
icon: HugeIcons.strokeRoundedInformationCircle,
|
|
isActive: _selectedTab == 6,
|
|
scale: s,
|
|
focusNode: _navFocusNodes[6],
|
|
onFocusChange: (focused) {
|
|
if (focused) _setSelectedTab(6);
|
|
},
|
|
onKeyEvent: (node, event) => _handleNavKey(6, event),
|
|
onTap: () => setState(() => _selectedTab = 6),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 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
|
|
? _buildPengumumanTab(s)
|
|
: _selectedTab == 4
|
|
? _buildJumatTab(s)
|
|
: _selectedTab == 5
|
|
? _buildSimulasiTab(s)
|
|
: _buildTentangTab(s),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _setSelectedTab(int index) {
|
|
if (_selectedTab == index) return;
|
|
setState(() => _selectedTab = index);
|
|
}
|
|
|
|
void _focusNavTab(int index) {
|
|
if (index < 0 || index >= _navFocusNodes.length) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_navFocusNodes[index].requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _focusIdentityRow(int index) {
|
|
if (_selectedTab != 0) return;
|
|
if (index < 0 || index >= _identityFocusNodes.length) return;
|
|
_requestFocusAndReveal(
|
|
_identityFocusNodes[index],
|
|
_identityScrollController,
|
|
);
|
|
}
|
|
|
|
void _focusJumatRow(int index) {
|
|
if (_selectedTab != 4) return;
|
|
if (index < 0 || index >= _jumatFocusNodes.length) return;
|
|
_requestFocusAndReveal(
|
|
_jumatFocusNodes[index],
|
|
_jumatScrollController,
|
|
);
|
|
}
|
|
|
|
void _focusSimulasiRow(int index) {
|
|
if (_selectedTab != 5) return;
|
|
if (index < 0 || index >= _simulasiFocusNodes.length) return;
|
|
_requestFocusAndReveal(
|
|
_simulasiFocusNodes[index],
|
|
_simulasiScrollController,
|
|
);
|
|
}
|
|
|
|
void _focusTentangRow(int index) {
|
|
if (_selectedTab != 6) return;
|
|
if (index < 0 || index >= _tentangRowCount()) return;
|
|
_requestFocusAndReveal(
|
|
_tentangFocusNodes[index],
|
|
_tentangScrollController,
|
|
);
|
|
}
|
|
|
|
int _tentangRowCount() {
|
|
return (_updateCheckResult?.updateAvailable ?? false) ? 2 : 1;
|
|
}
|
|
|
|
void _focusJadwalRow(int index) {
|
|
if (_selectedTab != 1) return;
|
|
if (index < 0 || index >= _jadwalFocusNodes.length) return;
|
|
_requestFocusAndReveal(
|
|
_jadwalFocusNodes[index],
|
|
_jadwalScrollController,
|
|
);
|
|
}
|
|
|
|
FocusNode _tampilanFocusNode(int index) {
|
|
if (index == 0) {
|
|
return _tampilanEntryFocusNode;
|
|
}
|
|
return _tampilanFocusNodes.putIfAbsent(
|
|
index,
|
|
() => FocusNode(debugLabel: 'tampilan_row_$index'),
|
|
);
|
|
}
|
|
|
|
FocusNode _pengumumanFocusNode(int index) {
|
|
if (index == 0) {
|
|
return _pengumumanEntryFocusNode;
|
|
}
|
|
return _pengumumanFocusNodes.putIfAbsent(
|
|
index,
|
|
() => FocusNode(debugLabel: 'pengumuman_row_$index'),
|
|
);
|
|
}
|
|
|
|
int _tampilanRowCount() {
|
|
var count = 0;
|
|
count += 8;
|
|
if (_useUnsplash) {
|
|
count += 2;
|
|
}
|
|
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) {
|
|
count += 1;
|
|
}
|
|
count += 1;
|
|
count += 1;
|
|
count += _slideshowImages.length;
|
|
count += 1;
|
|
return count;
|
|
}
|
|
|
|
void _focusTampilanRow(int index) {
|
|
if (_selectedTab != 2) return;
|
|
final max = _tampilanRowCount();
|
|
if (index < 0 || index >= max) return;
|
|
_requestFocusAndReveal(
|
|
_tampilanFocusNode(index),
|
|
_tampilanScrollController,
|
|
);
|
|
}
|
|
|
|
int _pengumumanRowCount() {
|
|
var count = 0;
|
|
count += 3;
|
|
count += _textSlides.length * 2;
|
|
count += 1;
|
|
count += 1;
|
|
count += _runningTexts.length * 3;
|
|
count += 1;
|
|
return count;
|
|
}
|
|
|
|
void _focusPengumumanRow(int index) {
|
|
if (_selectedTab != 3) return;
|
|
final max = _pengumumanRowCount();
|
|
if (index < 0 || index >= max) return;
|
|
_requestFocusAndReveal(
|
|
_pengumumanFocusNode(index),
|
|
_pengumumanScrollController,
|
|
);
|
|
}
|
|
|
|
void _requestFocusAndReveal(
|
|
FocusNode node,
|
|
ScrollController controller,
|
|
) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
node.requestFocus();
|
|
WidgetsBinding.instance.addPostFrameCallback((__) {
|
|
if (!mounted || !controller.hasClients) return;
|
|
final focusContext = node.context;
|
|
if (focusContext == null || !focusContext.mounted) return;
|
|
Scrollable.ensureVisible(
|
|
focusContext,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: 0.18,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
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:
|
|
_focusPengumumanRow(0);
|
|
return;
|
|
case 4:
|
|
_focusJumatRow(0);
|
|
return;
|
|
case 5:
|
|
_focusSimulasiRow(0);
|
|
return;
|
|
case 6:
|
|
_focusTentangRow(0);
|
|
return;
|
|
default:
|
|
target = null;
|
|
}
|
|
if (target == null) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
target!.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
KeyEventResult _handleNavKey(int index, KeyEvent event) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
_focusNavTab(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusNavTab(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight ||
|
|
key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.enter) {
|
|
_focusEntryForTab(index);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handleJadwalActionKey(
|
|
int index,
|
|
KeyEvent event, {
|
|
required VoidCallback onActivate,
|
|
}) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
_focusJadwalRow(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusJadwalRow(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
|
|
onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handleSimpleTabKey(KeyEvent event) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handleIdentityActionKey(
|
|
int index,
|
|
KeyEvent event, {
|
|
required VoidCallback onActivate,
|
|
}) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
_focusIdentityRow(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusIdentityRow(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
|
|
onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handleTampilanActionKey(
|
|
int index,
|
|
KeyEvent event, {
|
|
required VoidCallback onActivate,
|
|
}) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
_focusTampilanRow(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusTampilanRow(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
|
|
onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handlePengumumanActionKey(
|
|
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) {
|
|
_focusPengumumanRow(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusPengumumanRow(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
|
|
onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handleSimulasiActionKey(
|
|
int index,
|
|
KeyEvent event, {
|
|
required VoidCallback onActivate,
|
|
}) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
_focusSimulasiRow(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusSimulasiRow(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
|
|
onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
KeyEventResult _handleTentangActionKey(
|
|
int index,
|
|
KeyEvent event, {
|
|
required VoidCallback onActivate,
|
|
}) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
_focusTentangRow(index - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_focusTentangRow(index + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
_focusNavTab(_selectedTab);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
|
|
onActivate();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
Widget _buildJumatTab(double s) {
|
|
return FocusTraversalGroup(
|
|
policy: WidgetOrderTraversalPolicy(),
|
|
child: Focus(
|
|
canRequestFocus: false,
|
|
onKeyEvent: (node, event) => _handleSimpleTabKey(event),
|
|
child: SingleChildScrollView(
|
|
controller: _jumatScrollController,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Pengaturan Jumat',
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.secondary),
|
|
),
|
|
SizedBox(height: 8 * s),
|
|
Text(
|
|
'Data di bawah akan tampil setiap hari Jumat: pada layar utama (banner bawah jam) dan layar Persiapan Khutbah.',
|
|
style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant),
|
|
),
|
|
SizedBox(height: 40 * s),
|
|
|
|
_adminCard(s, child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Petugas Shalat Jumat', s),
|
|
SizedBox(height: 8 * s),
|
|
Text(
|
|
'Nama Khatib dan Imam tampil di layar utama setiap Jumat dan di layar Persiapan Khutbah.',
|
|
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_buildTextField(
|
|
'Nama Khatib Minggu Ini',
|
|
_khatibCtrl,
|
|
s,
|
|
focusNode: _jumatFocusNodes[0],
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveDown: () => _focusJumatRow(1),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_buildTextField(
|
|
'Nama Imam Minggu Ini',
|
|
_imamCtrl,
|
|
s,
|
|
focusNode: _jumatFocusNodes[1],
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusJumatRow(0),
|
|
),
|
|
SizedBox(height: 32 * s),
|
|
|
|
// Preview chip
|
|
if (_khatibCtrl.text.isNotEmpty || _imamCtrl.text.isNotEmpty) ...[
|
|
Text('Preview tampilan:', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)),
|
|
SizedBox(height: 10 * s),
|
|
Container(
|
|
padding: EdgeInsets.all(20 * s),
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.background,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(color: SacredColors.secondary.withValues(alpha: 0.2)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.star_rounded, color: SacredColors.secondary, size: 16 * s),
|
|
SizedBox(width: 8 * s),
|
|
Text('JUMAT MUBARAK', style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 14 * s, fontWeight: FontWeight.w800, color: SacredColors.secondary, letterSpacing: 2)),
|
|
SizedBox(width: 8 * s),
|
|
Icon(Icons.star_rounded, color: SacredColors.secondary, size: 16 * s),
|
|
SizedBox(width: 24 * s),
|
|
if (_khatibCtrl.text.isNotEmpty)
|
|
Text('KHATIB ${_khatibCtrl.text}', style: GoogleFonts.manrope(
|
|
fontSize: 14 * s, color: SacredColors.onSurface)),
|
|
if (_khatibCtrl.text.isNotEmpty && _imamCtrl.text.isNotEmpty)
|
|
Text(' | ', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)),
|
|
if (_imamCtrl.text.isNotEmpty)
|
|
Text('IMAM ${_imamCtrl.text}', style: GoogleFonts.manrope(
|
|
fontSize: 14 * s, color: SacredColors.onSurface)),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
],
|
|
],
|
|
)),
|
|
|
|
SizedBox(height: 32 * s),
|
|
|
|
// Info box
|
|
_adminCard(s, child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Kapan Digunakan?', s),
|
|
SizedBox(height: 16 * s),
|
|
_infoRow(Icons.tv, 'Layar Utama (Jumat)', 'Banner bawah jam berubah ke JUMAT MUBARAK, nama khatib & imam tampil di bawahnya.', s),
|
|
SizedBox(height: 12 * s),
|
|
_infoRow(Icons.timer_outlined, 'Layar Persiapan Khutbah', 'Saat menuju iqomah Dzuhur di hari Jumat, layar menampilkan judul PERSIAPAN KHUTBAH beserta nama petugas.', s),
|
|
SizedBox(height: 12 * s),
|
|
_infoRow(Icons.info_outline, 'Durasi Blank Screen', 'Durasi Black Screen setelah shalat Jumat dapat diatur di tab Jadwal & Sinkronisasi.', s),
|
|
],
|
|
)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _infoRow(IconData icon, String title, String desc, double s) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, color: SacredColors.secondary, size: 22 * s),
|
|
SizedBox(width: 12 * s),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: GoogleFonts.manrope(fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
|
|
SizedBox(height: 4 * s),
|
|
Text(desc, style: GoogleFonts.manrope(fontSize: 13 * s, color: SacredColors.onSurfaceVariant)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTampilanTab(double s) {
|
|
var row = 0;
|
|
final textScaleRow = row++;
|
|
final mainDurationRow = row++;
|
|
final slideDurationRow = row++;
|
|
final scaleLabelRow = row++;
|
|
final scaleBodyRow = row++;
|
|
final scaleRunningRow = row++;
|
|
final scaleTopHeaderRow = row++;
|
|
int? removeBrandedBgRow;
|
|
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) {
|
|
removeBrandedBgRow = row++;
|
|
}
|
|
final pickBrandedBgRow = row++;
|
|
final useUnsplashRow = row++;
|
|
int? unsplashKeywordRow;
|
|
int? unsplashRotationRow;
|
|
if (_useUnsplash) {
|
|
unsplashKeywordRow = row++;
|
|
unsplashRotationRow = row++;
|
|
}
|
|
final addSlideshowImageRow = row++;
|
|
final slideshowDeleteRows = List<int>.generate(
|
|
_slideshowImages.length,
|
|
(_) => row++,
|
|
);
|
|
final openPengumumanRow = 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(scaleTopHeaderRow),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_scaleSlider(
|
|
s: s,
|
|
label: 'Header Atas (Identitas & Tanggal)',
|
|
focusNode: _tampilanFocusNode(scaleTopHeaderRow),
|
|
value: _scaleTopHeader,
|
|
onChanged: (v) {
|
|
setState(() => _scaleTopHeader = v);
|
|
_queueTampilanAutoSave();
|
|
},
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusTampilanRow(scaleRunningRow),
|
|
onMoveDown: () => _focusTampilanRow(
|
|
removeBrandedBgRow ?? pickBrandedBgRow,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Foto Latar Utama (Branding Masjid)', s),
|
|
SizedBox(height: 16 * s),
|
|
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) ...[
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(SacredRadii.md),
|
|
child: Image.file(
|
|
File(_brandedBgImage!),
|
|
height: 180 * s,
|
|
width: double.infinity,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(
|
|
height: 180 * s,
|
|
width: double.infinity,
|
|
color: SacredColors.surfaceContainerLowest,
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
Icons.broken_image,
|
|
size: 36 * s,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 12 * s),
|
|
Text(
|
|
_brandedBgImage!.split('/').last,
|
|
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
SizedBox(height: 12 * s),
|
|
_buildTampilanActionButton(
|
|
rowIndex: removeBrandedBgRow!,
|
|
s: s,
|
|
onActivate: () {
|
|
setState(() => _brandedBgImage = null);
|
|
_queueTampilanAutoSave(
|
|
message: 'Foto latar otomatis dihapus dan tersimpan',
|
|
);
|
|
},
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() => _brandedBgImage = null);
|
|
_queueTampilanAutoSave(
|
|
message: 'Foto latar otomatis dihapus dan tersimpan',
|
|
);
|
|
},
|
|
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 20 * s),
|
|
label: Text(
|
|
'HAPUS FOTO LATAR',
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 14 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
] else
|
|
Text('Belum ada foto latar masjid.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)),
|
|
SizedBox(height: 16 * s),
|
|
_buildTampilanActionButton(
|
|
rowIndex: pickBrandedBgRow,
|
|
s: s,
|
|
onActivate: () async {
|
|
final res = await FilePicker.platform.pickFiles(type: FileType.image);
|
|
final selectedPath = res?.files.single.path;
|
|
if (selectedPath != null && File(selectedPath).existsSync()) {
|
|
setState(() => _brandedBgImage = selectedPath);
|
|
_queueTampilanAutoSave(
|
|
message: 'Foto latar otomatis tersimpan',
|
|
);
|
|
}
|
|
},
|
|
child: ElevatedButton.icon(
|
|
onPressed: () async {
|
|
final res = await FilePicker.platform.pickFiles(type: FileType.image);
|
|
final selectedPath = res?.files.single.path;
|
|
if (selectedPath != null &&
|
|
File(selectedPath).existsSync()) {
|
|
setState(() => _brandedBgImage = selectedPath);
|
|
_queueTampilanAutoSave(
|
|
message: 'Foto latar otomatis tersimpan',
|
|
);
|
|
}
|
|
},
|
|
icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s),
|
|
label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)),
|
|
style: _tvElevatedActionStyle(
|
|
s: s,
|
|
normalBackground: SacredColors.secondary,
|
|
normalForeground: SacredColors.onPrimary,
|
|
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s),
|
|
fontSize: 16 * s,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Background Layar Utama (Unsplash)', s),
|
|
SizedBox(height: 12 * s),
|
|
_buildTvBoolField(
|
|
s: s,
|
|
rowIndex: useUnsplashRow,
|
|
label: 'Gunakan Foto Unsplash API',
|
|
value: _useUnsplash,
|
|
onChanged: (val) {
|
|
final previous = _useUnsplash;
|
|
setState(() => _useUnsplash = val);
|
|
_queueTampilanAutoSave();
|
|
if (!previous && val) {
|
|
_focusTampilanRow(useUnsplashRow + 1);
|
|
} else if (previous && !val) {
|
|
_focusTampilanRow(useUnsplashRow);
|
|
}
|
|
},
|
|
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(addSlideshowImageRow),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Galeri Gambar Slideshow', s),
|
|
SizedBox(height: 16 * s),
|
|
_buildTampilanActionButton(
|
|
rowIndex: addSlideshowImageRow,
|
|
s: s,
|
|
onActivate: () async {
|
|
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
|
|
if (res != null) {
|
|
setState(() {
|
|
for (var path in res.paths) {
|
|
if (path != null &&
|
|
File(path).existsSync() &&
|
|
!_slideshowImages.contains(path)) {
|
|
_slideshowImages.add(path);
|
|
}
|
|
}
|
|
});
|
|
_queueTampilanAutoSave(
|
|
message: 'Galeri slideshow otomatis tersimpan',
|
|
);
|
|
}
|
|
},
|
|
child: ElevatedButton.icon(
|
|
onPressed: () async {
|
|
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
|
|
if (res != null) {
|
|
setState(() {
|
|
for (var path in res.paths) {
|
|
if (path != null &&
|
|
File(path).existsSync() &&
|
|
!_slideshowImages.contains(path)) {
|
|
_slideshowImages.add(path);
|
|
}
|
|
}
|
|
});
|
|
_queueTampilanAutoSave(
|
|
message: 'Galeri slideshow otomatis tersimpan',
|
|
);
|
|
}
|
|
},
|
|
icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onPrimary, size: 18 * s),
|
|
label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)),
|
|
style: _tvElevatedActionStyle(
|
|
s: s,
|
|
normalBackground: SacredColors.secondary,
|
|
normalForeground: SacredColors.onPrimary,
|
|
padding: EdgeInsets.symmetric(horizontal: 20 * s, vertical: 14 * s),
|
|
fontSize: 14 * s,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
if (_slideshowImages.isEmpty)
|
|
Text('Belum ada gambar slideshow.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant))
|
|
else
|
|
ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: _slideshowImages.length,
|
|
separatorBuilder: (_, __) => SizedBox(height: 12 * s),
|
|
itemBuilder: (context, idx) {
|
|
final path = _slideshowImages[idx];
|
|
return Container(
|
|
padding: EdgeInsets.all(16 * s),
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.md),
|
|
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(SacredRadii.sm),
|
|
child: Image.file(
|
|
File(path),
|
|
width: double.infinity,
|
|
height: 120 * s,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(
|
|
width: double.infinity,
|
|
height: 120 * s,
|
|
color: SacredColors.surfaceContainerHigh,
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
Icons.broken_image,
|
|
size: 32 * s,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 10 * s),
|
|
Text(
|
|
path.split('/').last,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurface),
|
|
),
|
|
SizedBox(height: 10 * s),
|
|
_buildTampilanActionButton(
|
|
rowIndex: slideshowDeleteRows[idx],
|
|
s: s,
|
|
onActivate: () {
|
|
setState(() => _slideshowImages.removeAt(idx));
|
|
_queueTampilanAutoSave(
|
|
message: 'Galeri slideshow otomatis tersimpan',
|
|
);
|
|
},
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() => _slideshowImages.removeAt(idx));
|
|
_queueTampilanAutoSave(
|
|
message: 'Galeri slideshow otomatis tersimpan',
|
|
);
|
|
},
|
|
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 18 * s),
|
|
label: Text(
|
|
'HAPUS FOTO',
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 13 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Pengumuman Dipisah ke Tab Sendiri', s),
|
|
SizedBox(height: 8 * s),
|
|
Text(
|
|
'Text slide tengah dan running text bawah sekarang dipindahkan ke tab Pengumuman agar halaman Tampilan & Media lebih ringkas.',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 14 * s,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_buildTampilanActionButton(
|
|
rowIndex: openPengumumanRow,
|
|
s: s,
|
|
onActivate: () {
|
|
_setSelectedTab(3);
|
|
_focusEntryForTab(3);
|
|
},
|
|
child: ElevatedButton.icon(
|
|
onPressed: () {
|
|
_setSelectedTab(3);
|
|
_focusEntryForTab(3);
|
|
},
|
|
icon: HugeIcon(
|
|
icon: HugeIcons.strokeRoundedNotification03,
|
|
color: SacredColors.onPrimary,
|
|
size: 18 * s,
|
|
),
|
|
label: Text(
|
|
'BUKA TAB PENGUMUMAN',
|
|
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: 40 * s),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPengumumanTab(double s) {
|
|
var row = 0;
|
|
final mainHeroDurationRow = row++;
|
|
final announcementDurationRow = row++;
|
|
final scaleTextSlideRow = row++;
|
|
final textSlideTextRows = <int>[];
|
|
final textSlideDeleteRows = <int>[];
|
|
for (var i = 0; i < _textSlides.length; i++) {
|
|
textSlideTextRows.add(row++);
|
|
textSlideDeleteRows.add(row++);
|
|
}
|
|
final addTextSlideRow = 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: _pengumumanScrollController,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Pengaturan Pengumuman',
|
|
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('Text Slide Tengah', s),
|
|
SizedBox(height: 12 * s),
|
|
_buildTvIntStepperField(
|
|
s: s,
|
|
label: 'Durasi Slide Utama Tengah',
|
|
focusNode: _pengumumanFocusNode(mainHeroDurationRow),
|
|
controller: _mainHeroDurCtrl,
|
|
fallback: 10,
|
|
min: 3,
|
|
max: 120,
|
|
suffix: 'detik',
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveDown: () => _focusPengumumanRow(announcementDurationRow),
|
|
onValueChanged: _queuePengumumanAutoSave,
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_buildTvIntStepperField(
|
|
s: s,
|
|
label: 'Durasi Tiap Text Slide Tengah',
|
|
focusNode: _pengumumanFocusNode(announcementDurationRow),
|
|
controller: _textSlideDurCtrl,
|
|
fallback: 7,
|
|
min: 3,
|
|
max: 60,
|
|
suffix: 'detik',
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusPengumumanRow(mainHeroDurationRow),
|
|
onMoveDown: () => _focusPengumumanRow(scaleTextSlideRow),
|
|
onValueChanged: _queuePengumumanAutoSave,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_scaleSlider(
|
|
s: s,
|
|
label: 'Ukuran Text Slide Tengah',
|
|
focusNode: _pengumumanFocusNode(scaleTextSlideRow),
|
|
value: _scaleTextSlideCenter,
|
|
onChanged: (v) {
|
|
setState(() => _scaleTextSlideCenter = v);
|
|
_queuePengumumanAutoSave();
|
|
},
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusPengumumanRow(announcementDurationRow),
|
|
onMoveDown: () => _focusPengumumanRow(
|
|
_textSlides.isEmpty
|
|
? addTextSlideRow
|
|
: textSlideTextRows.first,
|
|
),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
if (_textSlides.isEmpty)
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16 * s),
|
|
child: Text(
|
|
'Belum ada text slide. Klik TAMBAH untuk menambah slide.',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 16 * s,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: _textSlides.length,
|
|
separatorBuilder: (_, __) => SizedBox(height: 12 * s),
|
|
itemBuilder: (context, idx) {
|
|
final textCtrl =
|
|
TextEditingController(text: _textSlides[idx])
|
|
..selection = TextSelection.fromPosition(
|
|
TextPosition(offset: _textSlides[idx].length),
|
|
);
|
|
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),
|
|
_scrollAware(
|
|
controller: _pengumumanScrollController,
|
|
child: _TvEditableTextTile(
|
|
scale: s,
|
|
label: 'Isi Text Slide',
|
|
focusNode: _pengumumanFocusNode(
|
|
textSlideTextRows[idx],
|
|
),
|
|
controller: textCtrl,
|
|
maxLines: 3,
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusPengumumanRow(
|
|
idx == 0
|
|
? announcementDurationRow
|
|
: textSlideDeleteRows[idx - 1],
|
|
),
|
|
onMoveDown: () => _focusPengumumanRow(
|
|
textSlideDeleteRows[idx],
|
|
),
|
|
onChanged: (val) {
|
|
_textSlides[idx] = val;
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Text slide tengah otomatis tersimpan',
|
|
);
|
|
},
|
|
onEditComplete: () {
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Text slide tengah otomatis tersimpan',
|
|
);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(height: 10 * s),
|
|
_buildPengumumanActionButton(
|
|
rowIndex: textSlideDeleteRows[idx],
|
|
s: s,
|
|
onActivate: () {
|
|
setState(() {
|
|
_textSlides.removeAt(idx);
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Text slide tengah otomatis tersimpan',
|
|
);
|
|
},
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_textSlides.removeAt(idx);
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Text slide tengah otomatis tersimpan',
|
|
);
|
|
},
|
|
icon: HugeIcon(
|
|
icon: HugeIcons.strokeRoundedDelete01,
|
|
color: SacredColors.error,
|
|
size: 18 * s,
|
|
),
|
|
label: Text(
|
|
'HAPUS SLIDE',
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 13 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
SizedBox(height: 20 * s),
|
|
_buildPengumumanActionButton(
|
|
rowIndex: addTextSlideRow,
|
|
s: s,
|
|
onActivate: () {
|
|
setState(() {
|
|
_textSlides.add('');
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
message: 'Text slide tengah otomatis ditambahkan',
|
|
);
|
|
},
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_textSlides.add('');
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
message: 'Text slide tengah otomatis ditambahkan',
|
|
);
|
|
},
|
|
icon: HugeIcon(
|
|
icon: HugeIcons.strokeRoundedPlusSign,
|
|
color: SacredColors.primary,
|
|
size: 20 * s,
|
|
),
|
|
label: Text(
|
|
'TAMBAH TEXT SLIDE',
|
|
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: 24 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Running Text (Bagian Bawah)', s),
|
|
SizedBox(height: 12 * s),
|
|
_buildTvAdjustTile(
|
|
s: s,
|
|
focusNode: _pengumumanFocusNode(marqueeModeRow),
|
|
label: 'Mode Animasi Running Text',
|
|
valueLabel:
|
|
_marqueeAnimType == 'fade' ? 'Fade In-Out' : 'Marquee',
|
|
progress: _marqueeAnimType == 'fade' ? 1 : 0,
|
|
helperText:
|
|
'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk memilih.',
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusPengumumanRow(addTextSlideRow),
|
|
onMoveDown: () => _focusPengumumanRow(
|
|
_runningTexts.isEmpty
|
|
? addRunningTextRow
|
|
: runningTextTextRows.first,
|
|
),
|
|
onIncrement: () {
|
|
if (_marqueeAnimType != 'fade') {
|
|
setState(() => _marqueeAnimType = 'fade');
|
|
_queuePengumumanAutoSave();
|
|
}
|
|
},
|
|
onDecrement: () {
|
|
if (_marqueeAnimType != 'marquee') {
|
|
setState(() => _marqueeAnimType = 'marquee');
|
|
_queuePengumumanAutoSave();
|
|
}
|
|
},
|
|
),
|
|
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),
|
|
_scrollAware(
|
|
controller: _pengumumanScrollController,
|
|
child: _TvEditableTextTile(
|
|
scale: s,
|
|
label: 'Teks Berjalan',
|
|
focusNode: _pengumumanFocusNode(
|
|
runningTextTextRows[idx],
|
|
),
|
|
controller: textCtrl,
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusPengumumanRow(
|
|
idx == 0
|
|
? marqueeModeRow
|
|
: runningTextDeleteRows[idx - 1],
|
|
),
|
|
onMoveDown: () => _focusPengumumanRow(
|
|
runningTextDurationRows[idx],
|
|
),
|
|
onChanged: (val) {
|
|
_runningTexts[idx] = val;
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Teks berjalan otomatis tersimpan',
|
|
);
|
|
},
|
|
onEditComplete: () {
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Teks berjalan otomatis tersimpan',
|
|
);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(height: 12 * s),
|
|
SizedBox(
|
|
width: 180 * s,
|
|
child: _scrollAware(
|
|
controller: _pengumumanScrollController,
|
|
child: _TvEditableTextTile(
|
|
scale: s,
|
|
label: 'Durasi (detik)',
|
|
focusNode: _pengumumanFocusNode(
|
|
runningTextDurationRows[idx],
|
|
),
|
|
controller: durCtrl,
|
|
keyboardType: TextInputType.number,
|
|
onMoveLeft: () => _focusNavTab(_selectedTab),
|
|
onMoveUp: () => _focusPengumumanRow(
|
|
runningTextTextRows[idx],
|
|
),
|
|
onMoveDown: () => _focusPengumumanRow(
|
|
runningTextDeleteRows[idx],
|
|
),
|
|
onChanged: (val) {
|
|
_runningTextDurations[idx] =
|
|
int.tryParse(val) ?? 12;
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Teks berjalan otomatis tersimpan',
|
|
);
|
|
},
|
|
onEditComplete: () {
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Teks berjalan otomatis tersimpan',
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 10 * s),
|
|
_buildPengumumanActionButton(
|
|
rowIndex: runningTextDeleteRows[idx],
|
|
s: s,
|
|
onActivate: () {
|
|
setState(() {
|
|
_runningTexts.removeAt(idx);
|
|
_runningTextDurations.removeAt(idx);
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
message:
|
|
'Teks berjalan otomatis tersimpan',
|
|
);
|
|
},
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_runningTexts.removeAt(idx);
|
|
_runningTextDurations.removeAt(idx);
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
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),
|
|
_buildPengumumanActionButton(
|
|
rowIndex: addRunningTextRow,
|
|
s: s,
|
|
onActivate: () {
|
|
setState(() {
|
|
_runningTexts.add('');
|
|
_runningTextDurations.add(12);
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
message: 'Baris teks otomatis ditambahkan',
|
|
);
|
|
},
|
|
child: OutlinedButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_runningTexts.add('');
|
|
_runningTextDurations.add(12);
|
|
});
|
|
_queuePengumumanAutoSave(
|
|
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) {
|
|
if (onMoveLeft != null) {
|
|
onMoveLeft();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
if (onMoveUp != null) {
|
|
onMoveUp();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
if (onMoveDown != null) {
|
|
onMoveDown();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: _tvFocusable(
|
|
s: s,
|
|
scrollAware: false,
|
|
child: content,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildJadwalActionButton({
|
|
required int rowIndex,
|
|
required double s,
|
|
required VoidCallback onActivate,
|
|
Widget? child,
|
|
Widget Function(bool isFocused)? builder,
|
|
}) {
|
|
assert(child != null || builder != null);
|
|
final focusNode = _jadwalFocusNodes[rowIndex];
|
|
|
|
return _scrollAware(
|
|
controller: _jadwalScrollController,
|
|
child: Focus(
|
|
focusNode: focusNode,
|
|
onKeyEvent: (node, event) =>
|
|
_handleJadwalActionKey(rowIndex, event, onActivate: onActivate),
|
|
child: ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) {
|
|
final isFocused = focusNode.hasFocus;
|
|
return AnimatedScale(
|
|
scale: isFocused ? 1.01 : 1.0,
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: InkWell(
|
|
onTap: onActivate,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
|
|
decoration: BoxDecoration(
|
|
color: isFocused
|
|
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: isFocused
|
|
? SacredColors.primary.withValues(alpha: 0.95)
|
|
: Colors.transparent,
|
|
width: isFocused ? 3 : 0,
|
|
),
|
|
boxShadow: isFocused
|
|
? [
|
|
BoxShadow(
|
|
color: SacredColors.primary.withValues(alpha: 0.28),
|
|
blurRadius: 24 * s,
|
|
spreadRadius: 2 * s,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: ExcludeFocus(
|
|
child: builder?.call(isFocused) ?? child!,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTampilanActionButton({
|
|
required int rowIndex,
|
|
required double s,
|
|
required VoidCallback onActivate,
|
|
Widget? child,
|
|
Widget Function(bool isFocused)? builder,
|
|
}) {
|
|
assert(child != null || builder != null);
|
|
final focusNode = _tampilanFocusNode(rowIndex);
|
|
|
|
return _scrollAware(
|
|
controller: _tampilanScrollController,
|
|
child: Focus(
|
|
focusNode: focusNode,
|
|
onKeyEvent: (node, event) => _handleTampilanActionKey(
|
|
rowIndex,
|
|
event,
|
|
onActivate: onActivate,
|
|
),
|
|
child: ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) {
|
|
final isFocused = focusNode.hasFocus;
|
|
return AnimatedScale(
|
|
scale: isFocused ? 1.01 : 1.0,
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: InkWell(
|
|
onTap: onActivate,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
|
|
decoration: BoxDecoration(
|
|
color: isFocused
|
|
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: isFocused
|
|
? SacredColors.primary.withValues(alpha: 0.95)
|
|
: Colors.transparent,
|
|
width: isFocused ? 3 : 0,
|
|
),
|
|
boxShadow: isFocused
|
|
? [
|
|
BoxShadow(
|
|
color: SacredColors.primary.withValues(alpha: 0.28),
|
|
blurRadius: 24 * s,
|
|
spreadRadius: 2 * s,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: builder != null ? builder(isFocused) : child!,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPengumumanActionButton({
|
|
required int rowIndex,
|
|
required double s,
|
|
required VoidCallback onActivate,
|
|
Widget? child,
|
|
Widget Function(bool isFocused)? builder,
|
|
}) {
|
|
assert(child != null || builder != null);
|
|
final focusNode = _pengumumanFocusNode(rowIndex);
|
|
|
|
return _scrollAware(
|
|
controller: _pengumumanScrollController,
|
|
child: Focus(
|
|
focusNode: focusNode,
|
|
onKeyEvent: (node, event) => _handlePengumumanActionKey(
|
|
rowIndex,
|
|
event,
|
|
onActivate: onActivate,
|
|
),
|
|
child: ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) {
|
|
final isFocused = focusNode.hasFocus;
|
|
return AnimatedScale(
|
|
scale: isFocused ? 1.01 : 1.0,
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: InkWell(
|
|
onTap: onActivate,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
|
|
decoration: BoxDecoration(
|
|
color: isFocused
|
|
? SacredColors.surfaceContainerLow
|
|
.withValues(alpha: 0.96)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: isFocused
|
|
? SacredColors.primary.withValues(alpha: 0.95)
|
|
: Colors.transparent,
|
|
width: isFocused ? 3 : 0,
|
|
),
|
|
boxShadow: isFocused
|
|
? [
|
|
BoxShadow(
|
|
color: SacredColors.primary
|
|
.withValues(alpha: 0.28),
|
|
blurRadius: 24 * s,
|
|
spreadRadius: 2 * s,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: builder != null ? builder(isFocused) : child!,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTentangActionButton({
|
|
required int rowIndex,
|
|
required double s,
|
|
required VoidCallback onActivate,
|
|
Widget? child,
|
|
Widget Function(bool isFocused)? builder,
|
|
}) {
|
|
assert(child != null || builder != null);
|
|
final focusNode = _tentangFocusNodes[rowIndex];
|
|
|
|
return _scrollAware(
|
|
controller: _tentangScrollController,
|
|
child: Focus(
|
|
focusNode: focusNode,
|
|
onKeyEvent: (node, event) => _handleTentangActionKey(
|
|
rowIndex,
|
|
event,
|
|
onActivate: onActivate,
|
|
),
|
|
child: ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) {
|
|
final isFocused = focusNode.hasFocus;
|
|
return AnimatedScale(
|
|
scale: isFocused ? 1.01 : 1.0,
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: InkWell(
|
|
onTap: onActivate,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
|
|
decoration: BoxDecoration(
|
|
color: isFocused
|
|
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: isFocused
|
|
? SacredColors.primary.withValues(alpha: 0.95)
|
|
: Colors.transparent,
|
|
width: isFocused ? 3 : 0,
|
|
),
|
|
boxShadow: isFocused
|
|
? [
|
|
BoxShadow(
|
|
color: SacredColors.primary.withValues(alpha: 0.28),
|
|
blurRadius: 24 * s,
|
|
spreadRadius: 2 * s,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: ExcludeFocus(
|
|
child: builder?.call(isFocused) ?? child!,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTvChoiceField({
|
|
required double s,
|
|
required int rowIndex,
|
|
required String label,
|
|
required List<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',
|
|
);
|
|
},
|
|
onProgressChanged: (nextProgress) {
|
|
final mapped = (minOffset + ((maxOffset - minOffset) * nextProgress)).round();
|
|
final clamped = mapped.clamp(minOffset, maxOffset).toInt();
|
|
if (clamped == _hijriOffsetDays) return;
|
|
setState(() {
|
|
_hijriOffsetDays = clamped;
|
|
});
|
|
_queueJadwalAutoSave(
|
|
message:
|
|
'Offset Hijriah ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari tersimpan',
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPrayerCard(String name, String time, double s) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.5),
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
blurRadius: 10 * s,
|
|
offset: Offset(0, 4 * s),
|
|
)
|
|
]
|
|
),
|
|
padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 24 * s),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
name,
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 18 * s,
|
|
fontWeight: FontWeight.w600,
|
|
color: SacredColors.onSurfaceVariant,
|
|
letterSpacing: 2 * s,
|
|
),
|
|
),
|
|
SizedBox(height: 8 * s),
|
|
Text(
|
|
time,
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 42 * s,
|
|
fontWeight: FontWeight.w800,
|
|
color: SacredColors.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
ScrollController _scrollControllerForTab(int tabIndex) {
|
|
switch (tabIndex) {
|
|
case 0:
|
|
return _identityScrollController;
|
|
case 1:
|
|
return _jadwalScrollController;
|
|
case 2:
|
|
return _tampilanScrollController;
|
|
case 3:
|
|
return _pengumumanScrollController;
|
|
case 4:
|
|
return _jumatScrollController;
|
|
case 5:
|
|
return _simulasiScrollController;
|
|
case 6:
|
|
return _tentangScrollController;
|
|
default:
|
|
return _identityScrollController;
|
|
}
|
|
}
|
|
|
|
Widget _scrollAware({
|
|
required ScrollController controller,
|
|
required Widget child,
|
|
}) {
|
|
return Builder(
|
|
builder: (context) {
|
|
return Focus(
|
|
onFocusChange: (hasFocus) {
|
|
if (!hasFocus || !controller.hasClients) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) {
|
|
Scrollable.ensureVisible(
|
|
context,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: 0.18,
|
|
);
|
|
}
|
|
});
|
|
},
|
|
child: child,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
int _parseCtrlInt(TextEditingController ctrl, int fallback) {
|
|
return int.tryParse(ctrl.text.trim()) ?? fallback;
|
|
}
|
|
|
|
void _bumpCtrlInt(
|
|
TextEditingController ctrl, {
|
|
required int delta,
|
|
required int min,
|
|
required int max,
|
|
required int fallback,
|
|
}) {
|
|
final next = (_parseCtrlInt(ctrl, fallback) + delta).clamp(min, max);
|
|
setState(() {
|
|
ctrl.text = next.toString();
|
|
});
|
|
}
|
|
|
|
Widget _buildTvIntStepperField({
|
|
required double s,
|
|
required String label,
|
|
FocusNode? focusNode,
|
|
required TextEditingController controller,
|
|
required int fallback,
|
|
required int min,
|
|
required int max,
|
|
String suffix = '',
|
|
VoidCallback? onMoveLeft,
|
|
VoidCallback? onMoveUp,
|
|
VoidCallback? onMoveDown,
|
|
VoidCallback? onValueChanged,
|
|
}) {
|
|
final value = _parseCtrlInt(controller, fallback);
|
|
final valueLabel = suffix.isEmpty ? '$value' : '$value $suffix';
|
|
final denominator = max - min;
|
|
final progress = denominator <= 0 ? 0.0 : ((value - min) / denominator);
|
|
|
|
return _buildTvAdjustTile(
|
|
s: s,
|
|
focusNode: focusNode,
|
|
label: label,
|
|
valueLabel: valueLabel,
|
|
progress: progress.clamp(0.0, 1.0),
|
|
helperText:
|
|
'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk menyesuaikan nilai.',
|
|
onMoveLeft: onMoveLeft,
|
|
onMoveUp: onMoveUp,
|
|
onMoveDown: onMoveDown,
|
|
onIncrement: () {
|
|
_bumpCtrlInt(
|
|
controller,
|
|
delta: 1,
|
|
min: min,
|
|
max: max,
|
|
fallback: fallback,
|
|
);
|
|
onValueChanged?.call();
|
|
},
|
|
onDecrement: () {
|
|
_bumpCtrlInt(
|
|
controller,
|
|
delta: -1,
|
|
min: min,
|
|
max: max,
|
|
fallback: fallback,
|
|
);
|
|
onValueChanged?.call();
|
|
},
|
|
onProgressChanged: denominator <= 0
|
|
? null
|
|
: (nextProgress) {
|
|
final mapped = (min + ((max - min) * nextProgress))
|
|
.round()
|
|
.clamp(min, max)
|
|
.toInt();
|
|
if (mapped == _parseCtrlInt(controller, fallback)) return;
|
|
setState(() {
|
|
controller.text = mapped.toString();
|
|
});
|
|
onValueChanged?.call();
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTvAdjustTile({
|
|
required double s,
|
|
FocusNode? focusNode,
|
|
required String label,
|
|
required String valueLabel,
|
|
required double progress,
|
|
required String helperText,
|
|
VoidCallback? onMoveLeft,
|
|
VoidCallback? onMoveUp,
|
|
VoidCallback? onMoveDown,
|
|
required VoidCallback onIncrement,
|
|
required VoidCallback onDecrement,
|
|
ValueChanged<double>? onProgressChanged,
|
|
}) {
|
|
return _scrollAware(
|
|
controller: _scrollControllerForTab(_selectedTab),
|
|
child: _TvAdjustTile(
|
|
scale: s,
|
|
focusNode: focusNode,
|
|
label: label,
|
|
valueLabel: valueLabel,
|
|
progress: progress,
|
|
helperText: helperText,
|
|
onMoveLeft: onMoveLeft,
|
|
onMoveUp: onMoveUp,
|
|
onMoveDown: onMoveDown,
|
|
onIncrement: onIncrement,
|
|
onDecrement: onDecrement,
|
|
onProgressChanged: onProgressChanged,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField(
|
|
String label,
|
|
TextEditingController ctrl,
|
|
double s, {
|
|
int maxLines = 1,
|
|
FocusNode? focusNode,
|
|
TextInputType? keyboardType,
|
|
ValueChanged<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)),
|
|
onProgressChanged: (nextProgress) {
|
|
final mapped = (0.5 + (1.5 * nextProgress)).clamp(0.5, 2.0);
|
|
final snapped = (((mapped / step).round() * step).clamp(0.5, 2.0)).toDouble();
|
|
onChanged(snapped);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSimulasiTab(double s) {
|
|
final simulationOffset = ref.watch(mockTimeOffsetProvider);
|
|
final isSimulating = simulationOffset != Duration.zero;
|
|
final simulatedMinutes = simulationOffset.inMinutes;
|
|
|
|
return FocusTraversalGroup(
|
|
policy: WidgetOrderTraversalPolicy(),
|
|
child: Focus(
|
|
canRequestFocus: false,
|
|
onKeyEvent: (node, event) => _handleSimpleTabKey(event),
|
|
child: SingleChildScrollView(
|
|
controller: _simulasiScrollController,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Mode Simulasi Pengembang',
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 48 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.primary,
|
|
),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
Text(
|
|
'Gunakan tombol di bawah ini untuk melihat pratinjau bagaimana aplikasi bereaksi terhadap berbagai waktu dan status tanpa harus menunggu waktu sebenarnya.\nFitur ini bekerja dengan menggeser waktu aplikasi (Time Travel).',
|
|
style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.onSurfaceVariant),
|
|
),
|
|
SizedBox(height: 48 * s),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(20 * s),
|
|
decoration: BoxDecoration(
|
|
color: isSimulating
|
|
? SacredColors.error.withValues(alpha: 0.12)
|
|
: SacredColors.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: isSimulating
|
|
? SacredColors.error.withValues(alpha: 0.45)
|
|
: SacredColors.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
HugeIcon(
|
|
icon: isSimulating
|
|
? HugeIcons.strokeRoundedAlert02
|
|
: HugeIcons.strokeRoundedCheckmarkCircle02,
|
|
color: isSimulating
|
|
? SacredColors.error
|
|
: SacredColors.primary,
|
|
size: 28 * s,
|
|
),
|
|
SizedBox(width: 16 * s),
|
|
Expanded(
|
|
child: Text(
|
|
isSimulating
|
|
? 'Simulasi aktif (${simulatedMinutes >= 0 ? '+' : ''}$simulatedMinutes menit). Gunakan kartu pertama untuk keluar dan kembali ke waktu asli.'
|
|
: 'Simulasi tidak aktif. Pilih salah satu skenario di bawah untuk mulai menguji tampilan layar.',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 16 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 24 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: isSimulating ? 'Keluar dari Simulasi' : 'Gunakan Waktu Asli',
|
|
icon: isSimulating
|
|
? HugeIcons.strokeRoundedCancelCircle
|
|
: HugeIcons.strokeRoundedHome01,
|
|
desc: isSimulating
|
|
? 'Matikan mode simulasi dan kembali ke waktu sistem saat ini.'
|
|
: 'Pastikan aplikasi berjalan menggunakan waktu asli perangkat.',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateTimeOffset(Duration.zero),
|
|
),
|
|
focusNode: _simulasiFocusNodes[0],
|
|
rowIndex: 0,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: '15 Detik Sebelum Adzan',
|
|
icon: HugeIcons.strokeRoundedNotification03,
|
|
desc: 'Melompat ke 15 detik sebelum Adzan Dzuhur untuk memeriksa transisi terakhir menuju Adzan.',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('pre_adzan_15'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[1],
|
|
rowIndex: 1,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: 'Menuju Adzan',
|
|
icon: HugeIcons.strokeRoundedClock01,
|
|
desc: 'Melompat ke 2 menit sebelum Adzan Dzuhur hari ini.',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('pre_adzan'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[2],
|
|
rowIndex: 2,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: 'Selama Adzan',
|
|
icon: HugeIcons.strokeRoundedMegaphone01,
|
|
desc: 'Melompat ke tepat waktu Adzan Dzuhur berkumandang.',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('adzan'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[3],
|
|
rowIndex: 3,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: '15 Detik Sebelum Iqamah',
|
|
icon: HugeIcons.strokeRoundedTimer02,
|
|
desc: 'Melompat ke 15 detik sebelum Iqamah Dzuhur untuk memeriksa hitungan mundur terakhir.',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('pre_iqomah_15'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[4],
|
|
rowIndex: 4,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: 'Menuju Iqomah',
|
|
icon: HugeIcons.strokeRoundedTimer02,
|
|
desc: 'Melompat ke saat waktu iqomah sedang menghitung mundur (1 menit setelah Adzan).',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('iqomah'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[5],
|
|
rowIndex: 5,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: 'Persiapan Jumat',
|
|
icon: HugeIcons.strokeRoundedCalendar03,
|
|
desc: 'Menyimulasikan layar khusus persiapan Jumat (30 menit sebelum Adzan Dzuhur).',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('jumat_incoming'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[6],
|
|
rowIndex: 6,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: 'Khutbah Berlangsung',
|
|
icon: HugeIcons.strokeRoundedUserGroup,
|
|
desc: 'Menyimulasikan layar saat Khutbah sedang berlangsung tanpa hitungan mundur (2 menit setelah Adzan Dzuhur).',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('jumat_khutbah'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[7],
|
|
rowIndex: 7,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
_simulasiCard(
|
|
s: s,
|
|
title: 'Mode Shalat',
|
|
icon: HugeIcons.strokeRoundedMoon02,
|
|
desc: 'Layar menjadi hitam atau gelap selama shalat berlangsung.',
|
|
onTap: () => _activateSimulation(
|
|
() => _simulateEvent('shalat'),
|
|
),
|
|
focusNode: _simulasiFocusNodes[8],
|
|
rowIndex: 8,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTentangTab(double s) {
|
|
final currentVersion = _currentVersion;
|
|
final updateResult = _updateCheckResult;
|
|
final remote = updateResult?.remote;
|
|
|
|
return FocusTraversalGroup(
|
|
policy: WidgetOrderTraversalPolicy(),
|
|
child: Focus(
|
|
canRequestFocus: false,
|
|
onKeyEvent: (node, event) => _handleSimpleTabKey(event),
|
|
child: SingleChildScrollView(
|
|
controller: _tentangScrollController,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Tentang Aplikasi',
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 48 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.primary,
|
|
),
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
Text(
|
|
'Informasi aplikasi, kontak bantuan, dan pemeriksaan versi terbaru.',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 18 * s,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
SizedBox(height: 40 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Kontak Bantuan', s),
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Nama Pengembang',
|
|
'Dwindi Ramadhana',
|
|
HugeIcons.strokeRoundedUser,
|
|
s,
|
|
),
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Alamat',
|
|
'Yogyakarta, Indonesia',
|
|
HugeIcons.strokeRoundedLocation01,
|
|
s,
|
|
),
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Nomor Kontak',
|
|
'+62 812 2988 6864',
|
|
HugeIcons.strokeRoundedCall02,
|
|
s,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 32 * s),
|
|
_adminCard(
|
|
s,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_sectionLabel('Versi & Pembaruan', s),
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Versi Saat Ini',
|
|
currentVersion?.versionName ?? 'Memuat versi...',
|
|
HugeIcons.strokeRoundedPackage,
|
|
s,
|
|
),
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Sumber Update',
|
|
'files.jamshalat.com/latest.json',
|
|
HugeIcons.strokeRoundedLinkCircle02,
|
|
s,
|
|
),
|
|
if (updateResult != null) ...[
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Status',
|
|
_buildUpdateStatusLabel(updateResult),
|
|
updateResult.hasError
|
|
? HugeIcons.strokeRoundedAlert02
|
|
: updateResult.updateAvailable
|
|
? HugeIcons.strokeRoundedArrowDown01
|
|
: HugeIcons.strokeRoundedCheckmarkCircle02,
|
|
s,
|
|
),
|
|
],
|
|
if (remote != null) ...[
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Versi Remote',
|
|
remote.latestVersion,
|
|
HugeIcons.strokeRoundedPackage,
|
|
s,
|
|
),
|
|
if (remote.publishedAt != null) ...[
|
|
SizedBox(height: 20 * s),
|
|
_buildStatusRow(
|
|
'Tanggal Rilis',
|
|
DateFormat(
|
|
'dd MMM yyyy, HH:mm',
|
|
'id_ID',
|
|
).format(remote.publishedAt!.toLocal()),
|
|
HugeIcons.strokeRoundedCalendar03,
|
|
s,
|
|
),
|
|
],
|
|
if (remote.notes.isNotEmpty) ...[
|
|
SizedBox(height: 24 * s),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(24 * s),
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: SacredColors.outlineVariant.withValues(
|
|
alpha: 0.35,
|
|
),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Catatan Rilis',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 15 * s,
|
|
fontWeight: FontWeight.w700,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
SizedBox(height: 12 * s),
|
|
Text(
|
|
remote.notes,
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 18 * s,
|
|
fontWeight: FontWeight.w600,
|
|
color: SacredColors.onSurface,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
SizedBox(height: 24 * s),
|
|
_buildTentangActionButton(
|
|
rowIndex: 0,
|
|
s: s,
|
|
onActivate: _checkForUpdates,
|
|
builder: (isFocused) => _buildTvPrimaryActionSurface(
|
|
s: s,
|
|
isFocused: isFocused,
|
|
icon: _isCheckingUpdate
|
|
? SizedBox(
|
|
width: 24 * s,
|
|
height: 24 * s,
|
|
child: CircularProgressIndicator(
|
|
color: isFocused
|
|
? SacredColors.onPrimary
|
|
: SacredColors.onSecondary,
|
|
strokeWidth: 3,
|
|
),
|
|
)
|
|
: HugeIcon(
|
|
icon: HugeIcons.strokeRoundedRefresh,
|
|
color: isFocused
|
|
? SacredColors.onPrimary
|
|
: SacredColors.onSecondary,
|
|
),
|
|
label: _isCheckingUpdate
|
|
? 'MEMERIKSA UPDATE...'
|
|
: 'CEK UPDATE',
|
|
),
|
|
),
|
|
if (updateResult?.updateAvailable ?? false) ...[
|
|
SizedBox(height: 16 * s),
|
|
_buildTentangActionButton(
|
|
rowIndex: 1,
|
|
s: s,
|
|
onActivate: _installLatestUpdate,
|
|
builder: (isFocused) => _buildTvPrimaryActionSurface(
|
|
s: s,
|
|
isFocused: isFocused,
|
|
icon: _isInstallingUpdate
|
|
? SizedBox(
|
|
width: 24 * s,
|
|
height: 24 * s,
|
|
child: CircularProgressIndicator(
|
|
color: isFocused
|
|
? SacredColors.onPrimary
|
|
: SacredColors.onSecondary,
|
|
strokeWidth: 3,
|
|
),
|
|
)
|
|
: HugeIcon(
|
|
icon: HugeIcons.strokeRoundedArrowDown01,
|
|
color: isFocused
|
|
? SacredColors.onPrimary
|
|
: SacredColors.onSecondary,
|
|
),
|
|
label: _isInstallingUpdate
|
|
? 'MENGUNDUH UPDATE ${(100 * _updateDownloadProgress).toStringAsFixed(0)}%'
|
|
: 'UPDATE SEKARANG',
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _buildUpdateStatusLabel(UpdateCheckResult result) {
|
|
if (result.hasError) {
|
|
return result.errorMessage ?? 'Pemeriksaan update gagal';
|
|
}
|
|
if (result.updateAvailable) {
|
|
final remote = result.remote;
|
|
if (remote == null) return 'Update tersedia';
|
|
if (remote.latestVersion == result.current.versionName) {
|
|
return 'Update build tersedia untuk versi ${remote.latestVersion}';
|
|
}
|
|
return 'Update tersedia ke ${remote.latestVersion}';
|
|
}
|
|
return 'Versi ini sudah terbaru';
|
|
}
|
|
|
|
Widget _simulasiCard({
|
|
required double s,
|
|
required String title,
|
|
required dynamic icon,
|
|
required String desc,
|
|
required VoidCallback onTap,
|
|
required int rowIndex,
|
|
FocusNode? focusNode,
|
|
}) {
|
|
final node = focusNode ?? _simulasiFocusNodes[rowIndex];
|
|
return _scrollAware(
|
|
controller: _simulasiScrollController,
|
|
child: Focus(
|
|
focusNode: node,
|
|
onKeyEvent: (focusNode, event) => _handleSimulasiActionKey(
|
|
rowIndex,
|
|
event,
|
|
onActivate: onTap,
|
|
),
|
|
child: ListenableBuilder(
|
|
listenable: node,
|
|
builder: (context, _) {
|
|
final isFocused = node.hasFocus;
|
|
return AnimatedScale(
|
|
scale: isFocused ? 1.01 : 1.0,
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
|
|
decoration: BoxDecoration(
|
|
color: isFocused
|
|
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: isFocused
|
|
? SacredColors.primary.withValues(alpha: 0.95)
|
|
: Colors.transparent,
|
|
width: isFocused ? 3 : 0,
|
|
),
|
|
boxShadow: isFocused
|
|
? [
|
|
BoxShadow(
|
|
color: SacredColors.primary.withValues(alpha: 0.28),
|
|
blurRadius: 24 * s,
|
|
spreadRadius: 2 * s,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(24 * s),
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.lg),
|
|
border: Border.all(
|
|
color: SacredColors.outlineVariant.withValues(alpha: 0.5),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 10 * s,
|
|
offset: Offset(0, 4 * s),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
HugeIcon(
|
|
icon: icon,
|
|
color: SacredColors.primary,
|
|
size: 40 * s,
|
|
),
|
|
SizedBox(height: 16 * s),
|
|
Text(
|
|
title,
|
|
style: GoogleFonts.plusJakartaSans(
|
|
fontSize: 20 * s,
|
|
fontWeight: FontWeight.bold,
|
|
color: SacredColors.onSurface,
|
|
),
|
|
),
|
|
SizedBox(height: 8 * s),
|
|
Text(
|
|
desc,
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 14 * s,
|
|
color: SacredColors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _simulateTimeOffset(Duration offset) {
|
|
ref.read(mockTimeOffsetProvider.notifier).state = offset;
|
|
}
|
|
|
|
void _simulateEvent(String eventType) {
|
|
final schedule = ref.read(todayScheduleProvider);
|
|
if (schedule == null) return;
|
|
// We simulate using schedule.dzuhur
|
|
final dzuhurStr = schedule.dzuhur;
|
|
|
|
final parts = dzuhurStr.split(':');
|
|
final realNow = DateTime.now();
|
|
final dzuhurTime = DateTime(realNow.year, realNow.month, realNow.day, int.parse(parts[0]), int.parse(parts[1]));
|
|
|
|
DateTime targetTime;
|
|
|
|
switch (eventType) {
|
|
case 'pre_adzan_15':
|
|
targetTime = dzuhurTime.subtract(const Duration(seconds: 15));
|
|
break;
|
|
case 'pre_adzan':
|
|
targetTime = dzuhurTime.subtract(const Duration(minutes: 2));
|
|
break;
|
|
case 'adzan':
|
|
targetTime = dzuhurTime;
|
|
break;
|
|
case 'pre_iqomah_15':
|
|
final settings = ref.read(settingsProvider);
|
|
targetTime = dzuhurTime
|
|
.add(Duration(minutes: settings.iqomahDzuhur))
|
|
.subtract(const Duration(seconds: 15));
|
|
break;
|
|
case 'iqomah':
|
|
targetTime = dzuhurTime.add(const Duration(seconds: 45)); // During iqomah
|
|
break;
|
|
case 'jumat_incoming':
|
|
int diff = DateTime.friday - realNow.weekday;
|
|
DateTime nextFriday = realNow.add(Duration(days: diff));
|
|
// Target: next Friday at dzuhur time - 30 minutes
|
|
targetTime = DateTime(nextFriday.year, nextFriday.month, nextFriday.day, dzuhurTime.hour, dzuhurTime.minute).subtract(const Duration(minutes: 30));
|
|
break;
|
|
case 'jumat_khutbah':
|
|
int diff = DateTime.friday - realNow.weekday;
|
|
DateTime nextFriday = realNow.add(Duration(days: diff));
|
|
// Target: next Friday at dzuhur time + 3 minutes (safely past 2-min Adzan)
|
|
targetTime = DateTime(nextFriday.year, nextFriday.month, nextFriday.day, dzuhurTime.hour, dzuhurTime.minute).add(const Duration(minutes: 3));
|
|
break;
|
|
case 'shalat':
|
|
// Shalat mode usually happens after iqomah ends
|
|
final settings = ref.read(settingsProvider);
|
|
targetTime = dzuhurTime.add(Duration(minutes: settings.iqomahDzuhur + 1));
|
|
break;
|
|
default:
|
|
targetTime = realNow;
|
|
}
|
|
|
|
final offset = targetTime.difference(realNow);
|
|
_simulateTimeOffset(offset);
|
|
|
|
switch (eventType) {
|
|
case 'adzan':
|
|
unawaited(SoundService.instance.playAdzanBeep());
|
|
break;
|
|
case 'iqomah':
|
|
unawaited(SoundService.instance.playIqomahCountdown());
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _activateSimulation(VoidCallback action) {
|
|
action();
|
|
if (!mounted) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && Navigator.of(context).canPop()) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class _NavButton extends StatefulWidget {
|
|
final String title;
|
|
final dynamic icon;
|
|
final bool isActive;
|
|
final double scale;
|
|
final FocusNode? focusNode;
|
|
final ValueChanged<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;
|
|
final ValueChanged<double>? onProgressChanged;
|
|
|
|
const _TvAdjustTile({
|
|
required this.scale,
|
|
this.focusNode,
|
|
required this.label,
|
|
required this.valueLabel,
|
|
required this.progress,
|
|
required this.helperText,
|
|
this.onMoveLeft,
|
|
this.onMoveUp,
|
|
this.onMoveDown,
|
|
required this.onIncrement,
|
|
required this.onDecrement,
|
|
this.onProgressChanged,
|
|
});
|
|
|
|
@override
|
|
State<_TvAdjustTile> createState() => _TvAdjustTileState();
|
|
}
|
|
|
|
class _TvEditableTextTile extends StatefulWidget {
|
|
final double scale;
|
|
final String label;
|
|
final TextEditingController controller;
|
|
final FocusNode? focusNode;
|
|
final int maxLines;
|
|
final TextInputType? keyboardType;
|
|
final ValueChanged<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;
|
|
|
|
void _updateFromTouchPosition(double x, double width) {
|
|
if (widget.onProgressChanged == null || width <= 0) return;
|
|
final normalized = (x / width).clamp(0.0, 1.0);
|
|
widget.onProgressChanged!(normalized);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (widget.focusNode == null) {
|
|
_fallbackFocusNode.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
final key = event.logicalKey;
|
|
final isActivateKey = key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.enter ||
|
|
key == LogicalKeyboardKey.numpadEnter ||
|
|
key == LogicalKeyboardKey.space ||
|
|
key == LogicalKeyboardKey.gameButtonA;
|
|
|
|
if (isActivateKey) {
|
|
setState(() => _isEditing = !_isEditing);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowUp) {
|
|
if (widget.onMoveUp != null) {
|
|
widget.onMoveUp!.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowDown) {
|
|
if (widget.onMoveDown != null) {
|
|
widget.onMoveDown!.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) {
|
|
if (widget.onMoveLeft != null) {
|
|
widget.onMoveLeft!.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowRight) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
if (!_isEditing) return KeyEventResult.ignored;
|
|
|
|
if (key == LogicalKeyboardKey.arrowUp) {
|
|
setState(() => _isEditing = false);
|
|
if (widget.onMoveUp != null) {
|
|
widget.onMoveUp!.call();
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
setState(() => _isEditing = false);
|
|
if (widget.onMoveDown != null) {
|
|
widget.onMoveDown!.call();
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
widget.onDecrement();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (key == LogicalKeyboardKey.arrowRight) {
|
|
widget.onIncrement();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (key == LogicalKeyboardKey.escape) {
|
|
setState(() => _isEditing = false);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = widget.scale;
|
|
final highlight = _isFocused || _isEditing;
|
|
|
|
return Focus(
|
|
focusNode: _focusNode,
|
|
onKeyEvent: _handleKey,
|
|
onFocusChange: (value) {
|
|
setState(() {
|
|
_isFocused = value;
|
|
if (!value) _isEditing = false;
|
|
});
|
|
},
|
|
child: InkWell(
|
|
onTap: _focusNode.requestFocus,
|
|
borderRadius: BorderRadius.circular(SacredRadii.md),
|
|
child: AnimatedScale(
|
|
scale: highlight ? 1.01 : 1.0,
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 140),
|
|
curve: Curves.easeOutCubic,
|
|
padding: EdgeInsets.all(18 * s),
|
|
decoration: BoxDecoration(
|
|
color: highlight
|
|
? SacredColors.surfaceContainerLow
|
|
: SacredColors.surfaceContainerLowest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.md),
|
|
border: Border.all(
|
|
color: highlight
|
|
? SacredColors.primary.withValues(alpha: 0.95)
|
|
: SacredColors.outlineVariant.withValues(alpha: 0.25),
|
|
width: highlight ? 3 : 1,
|
|
),
|
|
boxShadow: highlight
|
|
? [
|
|
BoxShadow(
|
|
color: SacredColors.primary.withValues(alpha: 0.28),
|
|
blurRadius: 24 * s,
|
|
spreadRadius: 2 * s,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
widget.label,
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 16 * s,
|
|
fontWeight: FontWeight.w600,
|
|
color: SacredColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 14 * s,
|
|
vertical: 6 * s,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.sm),
|
|
border: Border.all(
|
|
color: SacredColors.primary.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
child: Text(
|
|
widget.valueLabel,
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 16 * s,
|
|
fontWeight: FontWeight.w800,
|
|
color: SacredColors.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 14 * s),
|
|
Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () {
|
|
_focusNode.requestFocus();
|
|
widget.onDecrement();
|
|
},
|
|
child: Container(
|
|
width: 36 * s,
|
|
height: 36 * s,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: _isEditing
|
|
? SacredColors.surfaceContainerHigh
|
|
: SacredColors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.sm),
|
|
border: Border.all(
|
|
color: _isEditing
|
|
? SacredColors.primary.withValues(alpha: 0.8)
|
|
: SacredColors.outlineVariant.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
child: Text(
|
|
'←',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 16 * s,
|
|
fontWeight: FontWeight.w800,
|
|
color: SacredColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 12 * s),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final barWidth = constraints.maxWidth;
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (details) {
|
|
_focusNode.requestFocus();
|
|
_updateFromTouchPosition(details.localPosition.dx, barWidth);
|
|
},
|
|
onHorizontalDragStart: widget.onProgressChanged == null
|
|
? null
|
|
: (_) {
|
|
_focusNode.requestFocus();
|
|
setState(() => _isEditing = true);
|
|
},
|
|
onHorizontalDragUpdate: widget.onProgressChanged == null
|
|
? null
|
|
: (details) => _updateFromTouchPosition(
|
|
details.localPosition.dx,
|
|
barWidth,
|
|
),
|
|
onHorizontalDragEnd: widget.onProgressChanged == null
|
|
? null
|
|
: (_) => setState(() => _isEditing = false),
|
|
onHorizontalDragCancel: widget.onProgressChanged == null
|
|
? null
|
|
: () => setState(() => _isEditing = false),
|
|
child: Container(
|
|
height: 6 * s,
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(3 * s),
|
|
),
|
|
child: FractionallySizedBox(
|
|
alignment: Alignment.centerLeft,
|
|
widthFactor: widget.progress.clamp(0.0, 1.0),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: SacredColors.primary,
|
|
borderRadius: BorderRadius.circular(3 * s),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(width: 12 * s),
|
|
GestureDetector(
|
|
onTap: () {
|
|
_focusNode.requestFocus();
|
|
widget.onIncrement();
|
|
},
|
|
child: Container(
|
|
width: 36 * s,
|
|
height: 36 * s,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: _isEditing
|
|
? SacredColors.surfaceContainerHigh
|
|
: SacredColors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(SacredRadii.sm),
|
|
border: Border.all(
|
|
color: _isEditing
|
|
? SacredColors.primary.withValues(alpha: 0.8)
|
|
: SacredColors.outlineVariant.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
child: Text(
|
|
'→',
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 16 * s,
|
|
fontWeight: FontWeight.w800,
|
|
color: SacredColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 10 * s),
|
|
Text(
|
|
_isEditing
|
|
? 'Mode ubah aktif. Gunakan ← → lalu tekan OK untuk selesai.'
|
|
: widget.helperText,
|
|
style: GoogleFonts.manrope(
|
|
fontSize: 11 * s,
|
|
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TvEditableTextTileState extends State<_TvEditableTextTile> {
|
|
late final FocusNode _fallbackFocusNode =
|
|
FocusNode(debugLabel: 'tv_edit_tile');
|
|
late final FocusNode _textFocusNode =
|
|
FocusNode(debugLabel: 'tv_edit_text');
|
|
bool _isFocused = false;
|
|
bool _isEditing = false;
|
|
|
|
FocusNode get _outerFocusNode => widget.focusNode ?? _fallbackFocusNode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_textFocusNode.canRequestFocus = false;
|
|
_textFocusNode.addListener(_handleTextFocusChange);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_textFocusNode.removeListener(_handleTextFocusChange);
|
|
_textFocusNode.dispose();
|
|
if (widget.focusNode == null) {
|
|
_fallbackFocusNode.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleTextFocusChange() {
|
|
if (!mounted) return;
|
|
if (!_textFocusNode.hasFocus && _isEditing) {
|
|
_finishEditing();
|
|
return;
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
void _startEditing() {
|
|
setState(() {
|
|
_isEditing = true;
|
|
_textFocusNode.canRequestFocus = true;
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_textFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _finishEditing() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isEditing = false;
|
|
_textFocusNode.canRequestFocus = false;
|
|
});
|
|
widget.onEditComplete?.call();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_outerFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
|
|
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
final key = event.logicalKey;
|
|
final isActivateKey = key == LogicalKeyboardKey.enter ||
|
|
key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.numpadEnter ||
|
|
key == LogicalKeyboardKey.space ||
|
|
key == LogicalKeyboardKey.gameButtonA;
|
|
if (!_isEditing &&
|
|
isActivateKey) {
|
|
_startEditing();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) {
|
|
if (widget.onMoveLeft != null) {
|
|
widget.onMoveLeft!.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowUp) {
|
|
if (widget.onMoveUp != null) {
|
|
widget.onMoveUp!.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
if (!_isEditing && key == LogicalKeyboardKey.arrowDown) {
|
|
if (widget.onMoveDown != null) {
|
|
widget.onMoveDown!.call();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
if (_isEditing && key == LogicalKeyboardKey.escape) {
|
|
_finishEditing();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (_isEditing &&
|
|
widget.maxLines == 1 &&
|
|
isActivateKey) {
|
|
_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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|