diff --git a/lib/data/local/models.dart b/lib/data/local/models.dart index 22c0b3a..a0886af 100644 --- a/lib/data/local/models.dart +++ b/lib/data/local/models.dart @@ -50,15 +50,19 @@ class AppSettings extends HiveObject { // Blank screen durations @HiveField(12) - int blankScreenNormal; // minutes + int blankScreenNormal; // minutes @HiveField(13) - int blankScreenJumat; // minutes + int blankScreenJumat; // minutes // Running text items @HiveField(14) List runningTexts; + // Center text-slide items (separate from running ticker at bottom). + @HiveField(35) + List textSlides; + // Friday officers @HiveField(15) String khatibName; @@ -81,6 +85,14 @@ class AppSettings extends HiveObject { @HiveField(32) String? lastAutoSyncAttemptDate; + // Center hero block duration on main screen (seconds). + @HiveField(33) + int mainCenterSlideDurationSec; + + // Per-text announcement slide duration on main screen (seconds). + @HiveField(34) + int announcementSlideDurationSec; + // Slideshow image paths (local) @HiveField(20) List slideshowImages; @@ -128,6 +140,14 @@ class AppSettings extends HiveObject { @HiveField(31) int hijriOffsetDays; + // Group: Top header (identity + date on upper area) + @HiveField(36) + double scaleTopHeader; + + // Group: Center text slides (pengumuman in main area) + @HiveField(37) + double scaleTextSlideCenter; + AppSettings({ this.masjidName = 'Masjid Al-Ikhlas', this.masjidAddress = 'Jl. Kebaikan No. 1', @@ -147,12 +167,18 @@ class AppSettings extends HiveObject { 'Mohon luruskan dan rapatkan shaf', 'Kajian rutin setiap Ahad pagi', ], + this.textSlides = const [ + 'Mohon luruskan dan rapatkan shaf', + 'Kajian rutin setiap Ahad pagi', + ], this.khatibName = 'Ust. Fulan, S.Ag', this.imamName = 'Ust. Alan, Lc', this.mainScreenDurationSec = 15, this.slideDurationSec = 10, this.lastSyncDate, this.lastAutoSyncAttemptDate, + this.mainCenterSlideDurationSec = 10, + this.announcementSlideDurationSec = 7, this.slideshowImages = const [], this.textScaleIndex = 1, this.useUnsplashBackground = false, @@ -165,6 +191,8 @@ class AppSettings extends HiveObject { this.scaleCardBody = 1.0, this.scaleRunningText = 1.0, this.hijriOffsetDays = 0, + this.scaleTopHeader = 1.0, + this.scaleTextSlideCenter = 1.0, }); AppSettings copyWith({ @@ -183,12 +211,15 @@ class AppSettings extends HiveObject { int? blankScreenNormal, int? blankScreenJumat, List? runningTexts, + List? textSlides, String? khatibName, String? imamName, int? mainScreenDurationSec, int? slideDurationSec, String? lastSyncDate, String? lastAutoSyncAttemptDate, + int? mainCenterSlideDurationSec, + int? announcementSlideDurationSec, List? slideshowImages, int? textScaleIndex, bool? useUnsplashBackground, @@ -201,6 +232,8 @@ class AppSettings extends HiveObject { double? scaleCardBody, double? scaleRunningText, int? hijriOffsetDays, + double? scaleTopHeader, + double? scaleTextSlideCenter, }) { return AppSettings( masjidName: masjidName ?? this.masjidName, @@ -218,18 +251,26 @@ class AppSettings extends HiveObject { blankScreenNormal: blankScreenNormal ?? this.blankScreenNormal, blankScreenJumat: blankScreenJumat ?? this.blankScreenJumat, runningTexts: runningTexts ?? this.runningTexts, + textSlides: textSlides ?? this.textSlides, khatibName: khatibName ?? this.khatibName, imamName: imamName ?? this.imamName, - mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec, + mainScreenDurationSec: + mainScreenDurationSec ?? this.mainScreenDurationSec, slideDurationSec: slideDurationSec ?? this.slideDurationSec, lastSyncDate: lastSyncDate ?? this.lastSyncDate, lastAutoSyncAttemptDate: lastAutoSyncAttemptDate ?? this.lastAutoSyncAttemptDate, + mainCenterSlideDurationSec: + mainCenterSlideDurationSec ?? this.mainCenterSlideDurationSec, + announcementSlideDurationSec: + announcementSlideDurationSec ?? this.announcementSlideDurationSec, slideshowImages: slideshowImages ?? this.slideshowImages, textScaleIndex: textScaleIndex ?? this.textScaleIndex, - useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground, + useUnsplashBackground: + useUnsplashBackground ?? this.useUnsplashBackground, unsplashKeyword: unsplashKeyword ?? this.unsplashKeyword, - unsplashRotationHours: unsplashRotationHours ?? this.unsplashRotationHours, + unsplashRotationHours: + unsplashRotationHours ?? this.unsplashRotationHours, brandedBgImage: brandedBgImage ?? this.brandedBgImage, runningTextDurations: runningTextDurations ?? this.runningTextDurations, marqueeAnimType: marqueeAnimType ?? this.marqueeAnimType, @@ -237,6 +278,9 @@ class AppSettings extends HiveObject { scaleCardBody: scaleCardBody ?? this.scaleCardBody, scaleRunningText: scaleRunningText ?? this.scaleRunningText, hijriOffsetDays: hijriOffsetDays ?? this.hijriOffsetDays, + scaleTopHeader: scaleTopHeader ?? this.scaleTopHeader, + scaleTextSlideCenter: + scaleTextSlideCenter ?? this.scaleTextSlideCenter, ); } } @@ -253,6 +297,7 @@ class AppSettingsAdapter extends TypeAdapter { for (int i = 0; i < numOfFields; i++) { fields[reader.readByte()] = reader.read(); } + final runningTexts = (fields[14] as List?)?.cast() ?? const []; return AppSettings( masjidName: fields[0] as String? ?? 'Masjid Al-Ikhlas', masjidAddress: fields[1] as String? ?? 'Jl. Kebaikan No. 1', @@ -268,13 +313,17 @@ class AppSettingsAdapter extends TypeAdapter { preAdzanLead: fields[11] as int? ?? 10, blankScreenNormal: fields[12] as int? ?? 15, blankScreenJumat: fields[13] as int? ?? 45, - runningTexts: (fields[14] as List?)?.cast() ?? const [], + runningTexts: runningTexts, + textSlides: + (fields[35] as List?)?.cast() ?? List.from(runningTexts), khatibName: fields[15] as String? ?? '', imamName: fields[16] as String? ?? '', mainScreenDurationSec: fields[17] as int? ?? 15, slideDurationSec: fields[18] as int? ?? 10, lastSyncDate: fields[19] as String?, lastAutoSyncAttemptDate: fields[32] as String?, + mainCenterSlideDurationSec: fields[33] as int? ?? 10, + announcementSlideDurationSec: fields[34] as int? ?? 7, slideshowImages: (fields[20] as List?)?.cast() ?? const [], textScaleIndex: fields[21] as int? ?? 1, useUnsplashBackground: fields[22] as bool? ?? false, @@ -287,46 +336,91 @@ class AppSettingsAdapter extends TypeAdapter { scaleCardBody: (fields[29] as num?)?.toDouble() ?? 1.0, scaleRunningText: (fields[30] as num?)?.toDouble() ?? 1.0, hijriOffsetDays: fields[31] as int? ?? 0, + scaleTopHeader: (fields[36] as num?)?.toDouble() ?? 1.0, + scaleTextSlideCenter: (fields[37] as num?)?.toDouble() ?? 1.0, ); } @override void write(BinaryWriter writer, AppSettings obj) { writer + ..writeByte(38) + ..writeByte(0) + ..write(obj.masjidName) + ..writeByte(1) + ..write(obj.masjidAddress) + ..writeByte(2) + ..write(obj.cityIdApi) + ..writeByte(3) + ..write(obj.cityDisplayName) + ..writeByte(4) + ..write(obj.showImsak) + ..writeByte(5) + ..write(obj.showTerbit) + ..writeByte(6) + ..write(obj.iqomahSubuh) + ..writeByte(7) + ..write(obj.iqomahDzuhur) + ..writeByte(8) + ..write(obj.iqomahAshar) + ..writeByte(9) + ..write(obj.iqomahMaghrib) + ..writeByte(10) + ..write(obj.iqomahIsya) + ..writeByte(11) + ..write(obj.preAdzanLead) + ..writeByte(12) + ..write(obj.blankScreenNormal) + ..writeByte(13) + ..write(obj.blankScreenJumat) + ..writeByte(14) + ..write(obj.runningTexts) + ..writeByte(35) + ..write(obj.textSlides) + ..writeByte(15) + ..write(obj.khatibName) + ..writeByte(16) + ..write(obj.imamName) + ..writeByte(17) + ..write(obj.mainScreenDurationSec) + ..writeByte(18) + ..write(obj.slideDurationSec) + ..writeByte(19) + ..write(obj.lastSyncDate) + ..writeByte(32) + ..write(obj.lastAutoSyncAttemptDate) ..writeByte(33) - ..writeByte(0)..write(obj.masjidName) - ..writeByte(1)..write(obj.masjidAddress) - ..writeByte(2)..write(obj.cityIdApi) - ..writeByte(3)..write(obj.cityDisplayName) - ..writeByte(4)..write(obj.showImsak) - ..writeByte(5)..write(obj.showTerbit) - ..writeByte(6)..write(obj.iqomahSubuh) - ..writeByte(7)..write(obj.iqomahDzuhur) - ..writeByte(8)..write(obj.iqomahAshar) - ..writeByte(9)..write(obj.iqomahMaghrib) - ..writeByte(10)..write(obj.iqomahIsya) - ..writeByte(11)..write(obj.preAdzanLead) - ..writeByte(12)..write(obj.blankScreenNormal) - ..writeByte(13)..write(obj.blankScreenJumat) - ..writeByte(14)..write(obj.runningTexts) - ..writeByte(15)..write(obj.khatibName) - ..writeByte(16)..write(obj.imamName) - ..writeByte(17)..write(obj.mainScreenDurationSec) - ..writeByte(18)..write(obj.slideDurationSec) - ..writeByte(19)..write(obj.lastSyncDate) - ..writeByte(32)..write(obj.lastAutoSyncAttemptDate) - ..writeByte(20)..write(obj.slideshowImages) - ..writeByte(21)..write(obj.textScaleIndex) - ..writeByte(22)..write(obj.useUnsplashBackground) - ..writeByte(23)..write(obj.unsplashKeyword) - ..writeByte(24)..write(obj.unsplashRotationHours) - ..writeByte(25)..write(obj.brandedBgImage) - ..writeByte(26)..write(obj.runningTextDurations) - ..writeByte(27)..write(obj.marqueeAnimType) - ..writeByte(28)..write(obj.scaleCardLabel) - ..writeByte(29)..write(obj.scaleCardBody) - ..writeByte(30)..write(obj.scaleRunningText) - ..writeByte(31)..write(obj.hijriOffsetDays); + ..write(obj.mainCenterSlideDurationSec) + ..writeByte(34) + ..write(obj.announcementSlideDurationSec) + ..writeByte(20) + ..write(obj.slideshowImages) + ..writeByte(21) + ..write(obj.textScaleIndex) + ..writeByte(22) + ..write(obj.useUnsplashBackground) + ..writeByte(23) + ..write(obj.unsplashKeyword) + ..writeByte(24) + ..write(obj.unsplashRotationHours) + ..writeByte(25) + ..write(obj.brandedBgImage) + ..writeByte(26) + ..write(obj.runningTextDurations) + ..writeByte(27) + ..write(obj.marqueeAnimType) + ..writeByte(28) + ..write(obj.scaleCardLabel) + ..writeByte(29) + ..write(obj.scaleCardBody) + ..writeByte(30) + ..write(obj.scaleRunningText) + ..writeByte(31) + ..write(obj.hijriOffsetDays) + ..writeByte(36) + ..write(obj.scaleTopHeader) + ..writeByte(37) + ..write(obj.scaleTextSlideCenter); } } @@ -426,14 +520,23 @@ class DailyPrayerScheduleAdapter extends TypeAdapter { void write(BinaryWriter writer, DailyPrayerSchedule obj) { writer ..writeByte(9) - ..writeByte(0)..write(obj.date) - ..writeByte(1)..write(obj.imsak) - ..writeByte(2)..write(obj.subuh) - ..writeByte(3)..write(obj.terbit) - ..writeByte(4)..write(obj.dhuha) - ..writeByte(5)..write(obj.dzuhur) - ..writeByte(6)..write(obj.ashar) - ..writeByte(7)..write(obj.maghrib) - ..writeByte(8)..write(obj.isya); + ..writeByte(0) + ..write(obj.date) + ..writeByte(1) + ..write(obj.imsak) + ..writeByte(2) + ..write(obj.subuh) + ..writeByte(3) + ..write(obj.terbit) + ..writeByte(4) + ..write(obj.dhuha) + ..writeByte(5) + ..write(obj.dzuhur) + ..writeByte(6) + ..write(obj.ashar) + ..writeByte(7) + ..write(obj.maghrib) + ..writeByte(8) + ..write(obj.isya); } } diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index 4ce3454..ef50ad5 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -36,6 +36,8 @@ class _AdminScreenState extends ConsumerState { final _mainDurCtrl = TextEditingController(); final _slideDurCtrl = TextEditingController(); + final _mainHeroDurCtrl = TextEditingController(); + final _textSlideDurCtrl = TextEditingController(); int _selectedTab = 0; bool _isSyncing = false; @@ -50,6 +52,7 @@ class _AdminScreenState extends ConsumerState { // Running text repeater String _marqueeAnimType = 'marquee'; + List _textSlides = []; List _runningTexts = []; List _runningTextDurations = []; @@ -57,6 +60,8 @@ class _AdminScreenState extends ConsumerState { 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(); @@ -75,11 +80,13 @@ class _AdminScreenState extends ConsumerState { 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; @@ -90,8 +97,10 @@ class _AdminScreenState extends ConsumerState { late final List _simulasiFocusNodes; late final List _tentangFocusNodes; final Map _tampilanFocusNodes = {}; + final Map _pengumumanFocusNodes = {}; Timer? _identityAutoSaveTimer; Timer? _tampilanAutoSaveTimer; + Timer? _pengumumanAutoSaveTimer; Timer? _jumatAutoSaveTimer; Timer? _jadwalAutoSaveTimer; Timer? _statusBadgeTimer; @@ -107,14 +116,15 @@ class _AdminScreenState extends ConsumerState { @override void initState() { super.initState(); - _selectedTab = widget.initialTab.clamp(0, 5); + _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( - 6, + 7, (index) => FocusNode(debugLabel: 'admin_nav_$index'), ); _identityFocusNodes = [ @@ -149,6 +159,8 @@ class _AdminScreenState extends ConsumerState { _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; @@ -156,6 +168,7 @@ class _AdminScreenState extends ConsumerState { _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 @@ -169,6 +182,8 @@ class _AdminScreenState extends ConsumerState { _scaleCardLabel = settings.scaleCardLabel; _scaleCardBody = settings.scaleCardBody; _scaleRunningText = settings.scaleRunningText; + _scaleTopHeader = settings.scaleTopHeader; + _scaleTextSlideCenter = settings.scaleTextSlideCenter; _khatibCtrl.text = settings.khatibName; _imamCtrl.text = settings.imamName; @@ -184,6 +199,8 @@ class _AdminScreenState extends ConsumerState { _mainDurCtrl.addListener(_queueTampilanAutoSave); _slideDurCtrl.addListener(_queueTampilanAutoSave); + _mainHeroDurCtrl.addListener(_queuePengumumanAutoSave); + _textSlideDurCtrl.addListener(_queuePengumumanAutoSave); _unsplashKeywordCtrl.addListener(_queueTampilanAutoSave); _unsplashRotationCtrl.addListener(_queueTampilanAutoSave); _masjidNameCtrl.addListener(_queueIdentityAutoSave); @@ -216,6 +233,8 @@ class _AdminScreenState extends ConsumerState { _cityCtrl.dispose(); _mainDurCtrl.dispose(); _slideDurCtrl.dispose(); + _mainHeroDurCtrl.dispose(); + _textSlideDurCtrl.dispose(); _unsplashKeywordCtrl.dispose(); _unsplashRotationCtrl.dispose(); _khatibCtrl.dispose(); @@ -231,12 +250,15 @@ class _AdminScreenState extends ConsumerState { _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(); @@ -261,6 +283,9 @@ class _AdminScreenState extends ConsumerState { for (final node in _tampilanFocusNodes.values) { node.dispose(); } + for (final node in _pengumumanFocusNodes.values) { + node.dispose(); + } super.dispose(); } @@ -298,12 +323,10 @@ class _AdminScreenState extends ConsumerState { s.unsplashKeyword = _unsplashKeywordCtrl.text.trim().isEmpty ? 'mosque' : _unsplashKeywordCtrl.text.trim(); s.unsplashRotationHours = int.tryParse(_unsplashRotationCtrl.text.trim()) ?? 6; s.brandedBgImage = _brandedBgImage; - s.runningTexts = List.from(_runningTexts); - s.runningTextDurations = List.from(_runningTextDurations); - s.marqueeAnimType = _marqueeAnimType; s.scaleCardLabel = _scaleCardLabel; s.scaleCardBody = _scaleCardBody; s.scaleRunningText = _scaleRunningText; + s.scaleTopHeader = _scaleTopHeader; return s; }); if (mounted) { @@ -321,6 +344,36 @@ class _AdminScreenState extends ConsumerState { ); } + Future _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 _saveJadwalSettings({ String message = 'Pengaturan jadwal otomatis tersimpan', }) async { @@ -409,7 +462,7 @@ class _AdminScreenState extends ConsumerState { _updateCheckResult = result; }); if (!result.updateAvailable && - _selectedTab == 5 && + _selectedTab == 6 && _tentangFocusNodes[1].hasFocus) { _focusTentangRow(0); } @@ -1036,8 +1089,8 @@ class _AdminScreenState extends ConsumerState { ), SizedBox(height: 16 * s), _NavButton( - title: 'PENGATURAN JUMAT', - icon: HugeIcons.strokeRoundedCalendar01, + title: 'PENGUMUMAN', + icon: HugeIcons.strokeRoundedNotification03, isActive: _selectedTab == 3, scale: s, focusNode: _navFocusNodes[3], @@ -1049,8 +1102,8 @@ class _AdminScreenState extends ConsumerState { ), SizedBox(height: 16 * s), _NavButton( - title: 'SIMULASI', - icon: HugeIcons.strokeRoundedClock01, + title: 'PENGATURAN JUMAT', + icon: HugeIcons.strokeRoundedCalendar01, isActive: _selectedTab == 4, scale: s, focusNode: _navFocusNodes[4], @@ -1062,8 +1115,8 @@ class _AdminScreenState extends ConsumerState { ), SizedBox(height: 16 * s), _NavButton( - title: 'TENTANG', - icon: HugeIcons.strokeRoundedInformationCircle, + title: 'SIMULASI', + icon: HugeIcons.strokeRoundedClock01, isActive: _selectedTab == 5, scale: s, focusNode: _navFocusNodes[5], @@ -1073,6 +1126,19 @@ class _AdminScreenState extends ConsumerState { 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), + ), ], ), ), @@ -1085,11 +1151,13 @@ class _AdminScreenState extends ConsumerState { ? _buildIdentityTab(s) : _selectedTab == 1 ? _buildJadwalTab(s) - : _selectedTab == 2 + : _selectedTab == 2 ? _buildTampilanTab(s) : _selectedTab == 3 - ? _buildJumatTab(s) + ? _buildPengumumanTab(s) : _selectedTab == 4 + ? _buildJumatTab(s) + : _selectedTab == 5 ? _buildSimulasiTab(s) : _buildTentangTab(s), ), @@ -1125,7 +1193,7 @@ class _AdminScreenState extends ConsumerState { } void _focusJumatRow(int index) { - if (_selectedTab != 3) return; + if (_selectedTab != 4) return; if (index < 0 || index >= _jumatFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -1135,7 +1203,7 @@ class _AdminScreenState extends ConsumerState { } void _focusSimulasiRow(int index) { - if (_selectedTab != 4) return; + if (_selectedTab != 5) return; if (index < 0 || index >= _simulasiFocusNodes.length) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -1145,7 +1213,7 @@ class _AdminScreenState extends ConsumerState { } void _focusTentangRow(int index) { - if (_selectedTab != 5) return; + if (_selectedTab != 6) return; if (index < 0 || index >= _tentangRowCount()) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -1178,9 +1246,19 @@ class _AdminScreenState extends ConsumerState { ); } + FocusNode _pengumumanFocusNode(int index) { + if (index == 0) { + return _pengumumanEntryFocusNode; + } + return _pengumumanFocusNodes.putIfAbsent( + index, + () => FocusNode(debugLabel: 'pengumuman_row_$index'), + ); + } + int _tampilanRowCount() { var count = 0; - count += 7; + count += 8; if (_useUnsplash) { count += 2; } @@ -1191,8 +1269,6 @@ class _AdminScreenState extends ConsumerState { count += 1; count += _slideshowImages.length; count += 1; - count += _runningTexts.length * 3; - count += 1; return count; } @@ -1207,6 +1283,28 @@ class _AdminScreenState extends ConsumerState { }); } + 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; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _pengumumanFocusNode(index).requestFocus(); + } + }); + } + void _focusEntryForTab(int index) { final FocusNode? target; switch (index) { @@ -1220,12 +1318,15 @@ class _AdminScreenState extends ConsumerState { _focusTampilanRow(0); return; case 3: - _focusJumatRow(0); + _focusPengumumanRow(0); return; case 4: - _focusSimulasiRow(0); + _focusJumatRow(0); return; case 5: + _focusSimulasiRow(0); + return; + case 6: _focusTentangRow(0); return; default: @@ -1370,6 +1471,38 @@ class _AdminScreenState extends ConsumerState { 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, { @@ -1571,6 +1704,12 @@ class _AdminScreenState extends ConsumerState { 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; @@ -1578,26 +1717,12 @@ class _AdminScreenState extends ConsumerState { unsplashKeywordRow = row++; unsplashRotationRow = row++; } - int? removeBrandedBgRow; - if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) { - removeBrandedBgRow = row++; - } - final pickBrandedBgRow = row++; final addSlideshowImageRow = row++; final slideshowDeleteRows = List.generate( _slideshowImages.length, (_) => row++, ); - final marqueeModeRow = row++; - final runningTextTextRows = []; - final runningTextDurationRows = []; - final runningTextDeleteRows = []; - for (var i = 0; i < _runningTexts.length; i++) { - runningTextTextRows.add(row++); - runningTextDurationRows.add(row++); - runningTextDeleteRows.add(row++); - } - final addRunningTextRow = row++; + final openPengumumanRow = row++; return FocusTraversalGroup( policy: WidgetOrderTraversalPolicy(), @@ -1711,51 +1836,24 @@ class _AdminScreenState extends ConsumerState { }, onMoveLeft: () => _focusNavTab(_selectedTab), onMoveUp: () => _focusTampilanRow(scaleBodyRow), - onMoveDown: () => _focusTampilanRow(useUnsplashRow), + onMoveDown: () => _focusTampilanRow(scaleTopHeaderRow), ), - SizedBox(height: 40 * s), - _sectionLabel('Background Layar Utama (Unsplash)', s), - SizedBox(height: 12 * s), - _buildTvBoolField( + SizedBox(height: 16 * s), + _scaleSlider( s: s, - rowIndex: useUnsplashRow, - label: 'Gunakan Foto Unsplash API', - value: _useUnsplash, - onChanged: (val) { - setState(() => _useUnsplash = val); + label: 'Header Atas (Identitas & Tanggal)', + focusNode: _tampilanFocusNode(scaleTopHeaderRow), + value: _scaleTopHeader, + onChanged: (v) { + setState(() => _scaleTopHeader = v); _queueTampilanAutoSave(); }, - trueLabel: 'Aktif', - falseLabel: 'Nonaktif', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(scaleRunningRow), + onMoveDown: () => _focusTampilanRow( + removeBrandedBgRow ?? pickBrandedBgRow, + ), ), - if (_useUnsplash) ...[ - SizedBox(height: 12 * s), - _buildTextField( - 'Kata Kunci (Contoh: mosque, architecture)', - _unsplashKeywordCtrl, - s, - focusNode: _tampilanFocusNode(unsplashKeywordRow!), - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusTampilanRow(useUnsplashRow), - onMoveDown: () => _focusTampilanRow(unsplashRotationRow!), - ), - SizedBox(height: 12 * s), - _buildTvIntStepperField( - s: s, - label: 'Rotasi Foto', - focusNode: _tampilanFocusNode(unsplashRotationRow!), - controller: _unsplashRotationCtrl, - fallback: 6, - min: 1, - max: 24, - suffix: 'jam', - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusTampilanRow(unsplashKeywordRow!), - onMoveDown: () => _focusTampilanRow( - removeBrandedBgRow ?? pickBrandedBgRow, - ), - ), - ], ], ), ), @@ -1866,6 +1964,55 @@ class _AdminScreenState extends ConsumerState { ), ), 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) { + setState(() => _useUnsplash = val); + _queueTampilanAutoSave(); + }, + trueLabel: 'Aktif', + falseLabel: 'Nonaktif', + ), + if (_useUnsplash) ...[ + SizedBox(height: 12 * s), + _buildTextField( + 'Kata Kunci (Contoh: mosque, architecture)', + _unsplashKeywordCtrl, + s, + focusNode: _tampilanFocusNode(unsplashKeywordRow!), + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(useUnsplashRow), + onMoveDown: () => _focusTampilanRow(unsplashRotationRow!), + ), + SizedBox(height: 12 * s), + _buildTvIntStepperField( + s: s, + label: 'Rotasi Foto', + focusNode: _tampilanFocusNode(unsplashRotationRow!), + controller: _unsplashRotationCtrl, + fallback: 6, + min: 1, + max: 24, + suffix: 'jam', + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusTampilanRow(unsplashKeywordRow!), + onMoveDown: () => _focusTampilanRow(addSlideshowImageRow), + ), + ], + ], + ), + ), + SizedBox(height: 24 * s), _adminCard( s, child: Column( @@ -2007,172 +2154,57 @@ class _AdminScreenState extends ConsumerState { ), ), SizedBox(height: 24 * s), - _adminCard(s, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionLabel('Running Text / Pengumuman', s), - SizedBox(height: 12 * s), - _buildTvChoiceField( - s: s, - rowIndex: marqueeModeRow, - label: 'Mode Animasi Running Text', - options: const ['Marquee', 'Fade In-Out'], - selectedIndex: _marqueeAnimType == 'fade' ? 1 : 0, - onChanged: (index) { - setState(() => _marqueeAnimType = index == 1 ? 'fade' : 'marquee'); - _queueTampilanAutoSave(); - }, - ), - SizedBox(height: 24 * s), - if (_runningTexts.isEmpty) - Padding( - padding: EdgeInsets.symmetric(vertical: 16 * s), - child: Text('Belum ada teks. Klik TAMBAH untuk menambah baris.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)), - ) - else - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _runningTexts.length, - separatorBuilder: (_, __) => SizedBox(height: 12 * s), - itemBuilder: (context, idx) { - final textCtrl = TextEditingController(text: _runningTexts[idx]) - ..selection = TextSelection.fromPosition(TextPosition(offset: _runningTexts[idx].length)); - final durCtrl = TextEditingController(text: _runningTextDurations[idx].toString()); - return Container( - padding: EdgeInsets.all(20 * s), - decoration: BoxDecoration( - color: SacredColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(SacredRadii.md), - border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 32 * s, - height: 32 * s, - alignment: Alignment.center, - decoration: BoxDecoration( - color: SacredColors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Text('${idx + 1}', style: GoogleFonts.manrope(fontSize: 14 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)), - ), - SizedBox(height: 12 * s), - _TvEditableTextTile( - scale: s, - label: 'Teks Pengumuman', - focusNode: _tampilanFocusNode(runningTextTextRows[idx]), - controller: textCtrl, - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusTampilanRow( - idx == 0 ? marqueeModeRow : runningTextDeleteRows[idx - 1], - ), - onMoveDown: () => _focusTampilanRow(runningTextDurationRows[idx]), - onChanged: (val) { - _runningTexts[idx] = val; - }, - onEditComplete: () { - _queueTampilanAutoSave( - message: 'Teks berjalan otomatis tersimpan', - ); - }, - ), - SizedBox(height: 12 * s), - SizedBox( - width: 180 * s, - child: _TvEditableTextTile( - scale: s, - label: 'Durasi (detik)', - focusNode: _tampilanFocusNode(runningTextDurationRows[idx]), - controller: durCtrl, - keyboardType: TextInputType.number, - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusTampilanRow(runningTextTextRows[idx]), - onMoveDown: () => _focusTampilanRow(runningTextDeleteRows[idx]), - onChanged: (val) { - _runningTextDurations[idx] = int.tryParse(val) ?? 12; - }, - onEditComplete: () { - _queueTampilanAutoSave( - message: 'Teks berjalan otomatis tersimpan', - ); - }, - ), - ), - SizedBox(height: 10 * s), - _buildTampilanActionButton( - rowIndex: runningTextDeleteRows[idx], - s: s, - onActivate: () { - setState(() { - _runningTexts.removeAt(idx); - _runningTextDurations.removeAt(idx); - }); - _queueTampilanAutoSave( - message: 'Teks berjalan otomatis tersimpan', - ); - }, - child: OutlinedButton.icon( - onPressed: () { - setState(() { - _runningTexts.removeAt(idx); - _runningTextDurations.removeAt(idx); - }); - _queueTampilanAutoSave( - message: 'Teks berjalan otomatis tersimpan', - ); - }, - icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 18 * s), - label: Text( - 'HAPUS BARIS', - style: GoogleFonts.plusJakartaSans( - fontSize: 13 * s, - fontWeight: FontWeight.w700, - color: SacredColors.error, - ), - ), - ), - ), - ], - ), - ); - }, - ), - SizedBox(height: 20 * s), - _buildTampilanActionButton( - rowIndex: addRunningTextRow, - s: s, - onActivate: () { - setState(() { - _runningTexts.add(''); - _runningTextDurations.add(12); - }); - _queueTampilanAutoSave( - message: 'Baris teks otomatis ditambahkan', - ); - }, - child: OutlinedButton.icon( - onPressed: () { - setState(() { - _runningTexts.add(''); - _runningTextDurations.add(12); - }); - _queueTampilanAutoSave( - message: 'Baris teks otomatis ditambahkan', - ); - }, - icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.primary, size: 20 * s), - label: Text('TAMBAH BARIS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, color: SacredColors.primary)), - style: OutlinedButton.styleFrom( - side: BorderSide(color: SacredColors.primary.withValues(alpha: 0.5)), - padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s), + _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), ], ), @@ -2181,6 +2213,538 @@ class _AdminScreenState extends ConsumerState { ); } + Widget _buildPengumumanTab(double s) { + var row = 0; + final mainHeroDurationRow = row++; + final announcementDurationRow = row++; + final scaleTextSlideRow = row++; + final textSlideTextRows = []; + final textSlideDeleteRows = []; + for (var i = 0; i < _textSlides.length; i++) { + textSlideTextRows.add(row++); + textSlideDeleteRows.add(row++); + } + final addTextSlideRow = row++; + final marqueeModeRow = row++; + final runningTextTextRows = []; + final runningTextDurationRows = []; + final runningTextDeleteRows = []; + for (var i = 0; i < _runningTexts.length; i++) { + runningTextTextRows.add(row++); + runningTextDurationRows.add(row++); + runningTextDeleteRows.add(row++); + } + final addRunningTextRow = row++; + + return FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) => _handleSimpleTabKey(event), + child: SingleChildScrollView( + controller: _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), + _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), + _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: _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), @@ -2479,6 +3043,77 @@ class _AdminScreenState extends ConsumerState { ); } + 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, @@ -3337,10 +3972,12 @@ class _AdminScreenState extends ConsumerState { case 2: return _tampilanScrollController; case 3: - return _jumatScrollController; + return _pengumumanScrollController; case 4: - return _simulasiScrollController; + return _jumatScrollController; case 5: + return _simulasiScrollController; + case 6: return _tentangScrollController; default: return _identityScrollController; diff --git a/lib/features/home/home_view.dart b/lib/features/home/home_view.dart index 2a8e951..64f3be3 100644 --- a/lib/features/home/home_view.dart +++ b/lib/features/home/home_view.dart @@ -126,7 +126,7 @@ class _HomeViewState extends ConsumerState { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => const AdminScreen( - initialTab: 4, + initialTab: 5, focusSelectedTabOnOpen: true, ), ), diff --git a/lib/features/home/main_screen.dart b/lib/features/home/main_screen.dart index 21cf32e..b5ef6d9 100644 --- a/lib/features/home/main_screen.dart +++ b/lib/features/home/main_screen.dart @@ -38,15 +38,26 @@ class MainScreen extends ConsumerWidget { final timeStr = DateFormat('HH:mm').format(clock); final secStr = DateFormat(':ss').format(clock); final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'id').format(clock); - final dateHijri = - ref.watch(hijriDateProvider).valueOrNull ?? HijriDateFormatter.format(clock); + final dateHijri = ref.watch(hijriDateProvider).valueOrNull ?? + HijriDateFormatter.format(clock); + final rotationElapsed = ref.watch(rotationElapsedProvider); + final centerTextSlides = settings.textSlides + .map((text) => text.trim()) + .where((text) => text.isNotEmpty) + .toList(); + final centerSlide = _resolveCenterSlide( + settings: settings, + elapsedInMainWindowSec: rotationElapsed, + announcements: centerTextSlides, + ); return Container( color: SacredColors.background, child: Stack( children: [ // ── Underlay 1: Branded local image (highest priority if set) ── - if (settings.brandedBgImage != null && settings.brandedBgImage!.isNotEmpty) + if (settings.brandedBgImage != null && + settings.brandedBgImage!.isNotEmpty) Positioned.fill( child: Image.file( File(settings.brandedBgImage!), @@ -100,97 +111,38 @@ class MainScreen extends ConsumerWidget { child: Column( children: [ // ── HEADER ── - _buildHeader(context, s, fs, settings, dateGregorian, dateHijri), + _buildHeader( + context, + s, + fs, + settings, + dateGregorian, + dateHijri, + inlineClockText: + centerSlide.isPrimary ? null : '$timeStr$secStr', + ), // ── CENTER: Clock + Countdown ── Expanded( child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Countdown pill - if (screenData.nextPrayer != null && - screenData.timeUntilNext != null) - _buildCountdownPill(s, fs, screenData), - - SizedBox(height: 16 * s), - - // Massive Clock - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - timeStr, - style: GoogleFonts.plusJakartaSans( - fontSize: 180 * s, - fontWeight: FontWeight.w800, - color: SacredColors.onSurface, - letterSpacing: -6 * s, - height: 1.0, - shadows: [ - Shadow( - blurRadius: 40 * s, - color: - SacredColors.primary.withValues(alpha: 0.2), - ), - ], - ), - ), - Padding( - padding: EdgeInsets.only(top: 24 * s), - child: Text( - secStr, - style: GoogleFonts.plusJakartaSans( - fontSize: 72 * s, - fontWeight: FontWeight.w700, - color: SacredColors.primary, - letterSpacing: -1 * s, - ), - ), - ), - ], - ), - - // Decorative line - Container( - width: 240 * s, - height: 2 * s, - margin: EdgeInsets.only(top: 12 * s), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.transparent, - SacredColors.primary.withValues(alpha: 0.4), - Colors.transparent, - ], - ), + child: centerSlide.isPrimary + ? _buildPrimaryCenter( + s, + fs, + timeStr, + secStr, + dateGregorian, + screenData, + schedule, + settings, + ) + : _buildAnnouncementCenter( + s, + fs, + settings, + centerSlide, + centerTextSlides, ), - ), - - SizedBox(height: 16 * s), - - // Date line - Text( - dateGregorian, - style: GoogleFonts.manrope( - fontSize: 24 * fs, - fontWeight: FontWeight.w500, - color: SacredColors.onSurfaceVariant, - letterSpacing: 1 * s, - ), - ), - - // Secondary times (Imsak, Terbit, Dhuha) - if (schedule != null) - Padding( - padding: EdgeInsets.only(top: 24 * s), - child: _buildSecondaryTimes(s, fs, schedule, settings), - ), - - // Removed FRIDAY SPECIAL PANEL since its handled by dedicated JumatScreen - ], - ), ), ), @@ -213,108 +165,349 @@ class MainScreen extends ConsumerWidget { ); } - Widget _buildHeader( - BuildContext context, - double s, - double fs, - AppSettings settings, - String dateGregorian, - String dateHijri, - ) { + Widget _buildHeader(BuildContext context, double s, double fs, + AppSettings settings, String dateGregorian, String dateHijri, + {String? inlineClockText}) { + final hScale = settings.scaleTopHeader; + final showInlineClock = + inlineClockText != null && inlineClockText.isNotEmpty; + return Padding( padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Left: Mosque name + address - Padding( - padding: EdgeInsets.all(8.0 * s), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - settings.masjidName, - style: GoogleFonts.plusJakartaSans( - fontSize: 32 * s, - fontWeight: FontWeight.w700, - color: SacredColors.primary, - letterSpacing: -0.5 * s, + Expanded( + flex: 5, + child: Padding( + padding: EdgeInsets.all(8.0 * s), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + settings.masjidName, + style: GoogleFonts.plusJakartaSans( + fontSize: 32 * s * hScale, + fontWeight: FontWeight.w700, + color: SacredColors.primary, + letterSpacing: -0.5 * s, + ), ), - ), - SizedBox(height: 4 * s), - Row( - children: [ - HugeIcon( - icon: HugeIcons.strokeRoundedLocation01, - color: SacredColors.secondary, - size: 16 * s, - ), - SizedBox(width: 4 * s), - Text( - settings.masjidAddress, - style: GoogleFonts.manrope( - fontSize: 14 * fs, - fontWeight: FontWeight.w500, - color: SacredColors.onSurface.withValues(alpha: 0.7), - letterSpacing: 0.5 * s, + SizedBox(height: 4 * s), + Row( + children: [ + HugeIcon( + icon: HugeIcons.strokeRoundedLocation01, + color: SacredColors.secondary, + size: 16 * s * hScale, ), - ), - ], - ), - ], + SizedBox(width: 4 * s), + Expanded( + child: Text( + settings.masjidAddress, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope( + fontSize: 14 * fs * hScale, + fontWeight: FontWeight.w500, + color: + SacredColors.onSurface.withValues(alpha: 0.7), + letterSpacing: 0.5 * s, + ), + ), + ), + ], + ), + ], + ), ), ), - // Right: Hijri date + mosque icon - Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - dateHijri, - style: GoogleFonts.plusJakartaSans( - fontSize: 20 * s, - fontWeight: FontWeight.w700, - color: SacredColors.onSurface, + if (showInlineClock) + Expanded( + flex: 2, + child: Center( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 22 * s, + vertical: 10 * s, + ), + decoration: BoxDecoration( + color: SacredColors.surfaceContainerLow + .withValues(alpha: 0.86), + borderRadius: BorderRadius.circular(SacredRadii.full), + border: Border.all( + color: SacredColors.primary.withValues(alpha: 0.32), ), ), - Text( - dateGregorian.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 12 * fs, - fontWeight: FontWeight.w500, - color: SacredColors.onSurfaceVariant, - letterSpacing: 2 * s, + child: Text( + inlineClockText, + style: GoogleFonts.plusJakartaSans( + fontSize: 30 * s * hScale, + fontWeight: FontWeight.w800, + color: SacredColors.onSurface, + letterSpacing: -1 * s, + ), + ), + ), + ), + ), + + // Right: Hijri date + mosque icon + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + dateHijri, + style: GoogleFonts.plusJakartaSans( + fontSize: 20 * s * hScale, + fontWeight: FontWeight.w700, + color: SacredColors.onSurface, + ), + ), + Text( + dateGregorian.toUpperCase(), + style: GoogleFonts.manrope( + fontSize: 12 * fs * hScale, + fontWeight: FontWeight.w500, + color: SacredColors.onSurfaceVariant, + letterSpacing: 2 * s, + ), + ), + ], + ), + SizedBox(width: 16 * s), + Container( + width: 48 * s * hScale, + height: 48 * s * hScale, + decoration: BoxDecoration( + color: SacredColors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: HugeIcon( + icon: HugeIcons.strokeRoundedHome01, + color: SacredColors.secondary, + size: 28 * s * hScale, ), ), ], ), - SizedBox(width: 16 * s), - Container( - width: 48 * s, - height: 48 * s, - decoration: BoxDecoration( - color: SacredColors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: HugeIcon( - icon: HugeIcons.strokeRoundedHome01, - color: SacredColors.secondary, - size: 28 * s, - ), - ), - ], + ), ), ], ), ); } + _CenterSlideState _resolveCenterSlide({ + required AppSettings settings, + required int elapsedInMainWindowSec, + required List announcements, + }) { + final heroDuration = settings.mainCenterSlideDurationSec.clamp(1, 600); + final announcementDuration = + settings.announcementSlideDurationSec.clamp(1, 600); + final totalMainDuration = announcements.isEmpty + ? settings.mainScreenDurationSec.clamp(1, 600) + : heroDuration + (announcements.length * announcementDuration); + final elapsed = elapsedInMainWindowSec % totalMainDuration; + + if (elapsed < heroDuration || announcements.isEmpty) { + return _CenterSlideState.primary(heroDuration: heroDuration); + } + + final elapsedAfterHero = elapsed - heroDuration; + final offset = elapsedAfterHero ~/ announcementDuration; + final announcementIndex = offset % announcements.length; + final elapsedInSlide = elapsedAfterHero % announcementDuration; + + return _CenterSlideState.announcement( + announcementIndex: announcementIndex, + elapsedInSlideSec: elapsedInSlide, + slideDurationSec: announcementDuration, + totalAnnouncements: announcements.length, + ); + } + + Widget _buildPrimaryCenter( + double s, + double fs, + String timeStr, + String secStr, + String dateGregorian, + ScreenStateData screenData, + DailyPrayerSchedule? schedule, + AppSettings settings, + ) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (screenData.nextPrayer != null && screenData.timeUntilNext != null) + _buildCountdownPill(s, fs, screenData), + SizedBox(height: 16 * s), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + timeStr, + style: GoogleFonts.plusJakartaSans( + fontSize: 180 * s, + fontWeight: FontWeight.w800, + color: SacredColors.onSurface, + letterSpacing: -6 * s, + height: 1.0, + shadows: [ + Shadow( + blurRadius: 40 * s, + color: SacredColors.primary.withValues(alpha: 0.2), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only(top: 24 * s), + child: Text( + secStr, + style: GoogleFonts.plusJakartaSans( + fontSize: 72 * s, + fontWeight: FontWeight.w700, + color: SacredColors.primary, + letterSpacing: -1 * s, + ), + ), + ), + ], + ), + Container( + width: 240 * s, + height: 2 * s, + margin: EdgeInsets.only(top: 12 * s), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + SacredColors.primary.withValues(alpha: 0.4), + Colors.transparent, + ], + ), + ), + ), + SizedBox(height: 16 * s), + Text( + dateGregorian, + style: GoogleFonts.manrope( + fontSize: 24 * fs, + fontWeight: FontWeight.w500, + color: SacredColors.onSurfaceVariant, + letterSpacing: 1 * s, + ), + ), + if (schedule != null) + Padding( + padding: EdgeInsets.only(top: 24 * s), + child: _buildSecondaryTimes(s, fs, schedule, settings), + ), + ], + ); + } + + Widget _buildAnnouncementCenter( + double s, + double fs, + AppSettings settings, + _CenterSlideState state, + List announcements, + ) { + final slideScale = settings.scaleTextSlideCenter; + final index = state.announcementIndex ?? 0; + final text = announcements[index]; + final progress = state.slideDurationSec <= 0 + ? 0.0 + : (state.elapsedInSlideSec / state.slideDurationSec).clamp(0.0, 1.0); + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: 1320 * s), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + transitionBuilder: (child, animation) { + final slide = Tween( + begin: const Offset(0.16, 0), + end: Offset.zero, + ).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: slide, child: child), + ); + }, + child: SizedBox( + key: ValueKey('announcement-$index-$text'), + width: double.infinity, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 20 * s), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'PENGUMUMAN ${index + 1}/${state.totalAnnouncements}', + style: GoogleFonts.plusJakartaSans( + fontSize: 20 * fs * slideScale, + fontWeight: FontWeight.w800, + color: SacredColors.secondary, + letterSpacing: 1.2 * s, + ), + ), + SizedBox(height: 20 * s), + Text( + text, + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 72 * fs * slideScale, + fontWeight: FontWeight.w700, + color: SacredColors.onSurface, + height: 1.15, + shadows: [ + Shadow( + blurRadius: 32 * s, + color: SacredColors.background.withValues(alpha: 0.65), + ), + ], + ), + ), + SizedBox(height: 24 * s), + SizedBox( + width: 900 * s, + child: ClipRRect( + borderRadius: BorderRadius.circular(SacredRadii.full), + child: LinearProgressIndicator( + value: progress, + minHeight: 6 * s, + backgroundColor: + SacredColors.outlineVariant.withValues(alpha: 0.25), + valueColor: + AlwaysStoppedAnimation(SacredColors.primary), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + Widget _buildCountdownPill(double s, double fs, ScreenStateData screenData) { return Container( - padding: - EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s), + padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s), decoration: BoxDecoration( borderRadius: BorderRadius.circular(SacredRadii.full), border: Border.all( @@ -393,8 +586,6 @@ class MainScreen extends ConsumerWidget { ); } - - Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule, ScreenStateData screenData, AppSettings settings, DateTime clock) { final prayers = [ @@ -489,6 +680,46 @@ class MainScreen extends ConsumerWidget { // ─── Supporting widgets ─── +class _CenterSlideState { + final bool isPrimary; + final int? announcementIndex; + final int elapsedInSlideSec; + final int slideDurationSec; + final int totalAnnouncements; + + const _CenterSlideState._({ + required this.isPrimary, + this.announcementIndex, + required this.elapsedInSlideSec, + required this.slideDurationSec, + required this.totalAnnouncements, + }); + + factory _CenterSlideState.primary({required int heroDuration}) { + return _CenterSlideState._( + isPrimary: true, + elapsedInSlideSec: 0, + slideDurationSec: heroDuration, + totalAnnouncements: 0, + ); + } + + factory _CenterSlideState.announcement({ + required int announcementIndex, + required int elapsedInSlideSec, + required int slideDurationSec, + required int totalAnnouncements, + }) { + return _CenterSlideState._( + isPrimary: false, + announcementIndex: announcementIndex, + elapsedInSlideSec: elapsedInSlideSec, + slideDurationSec: slideDurationSec, + totalAnnouncements: totalAnnouncements, + ); + } +} + class _SecondaryTimeItem { final String label; final String time; @@ -508,8 +739,8 @@ class _PrayerCard extends StatelessWidget { final bool isFriday; final double s; final double fs; - final double scaleLabel; // controls prayer name label size - final double scaleBody; // controls time + iqomah text size + final double scaleLabel; // controls prayer name label size + final double scaleBody; // controls time + iqomah text size const _PrayerCard({ required this.data, @@ -590,7 +821,6 @@ class _PrayerCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ], - ), ); } @@ -664,7 +894,7 @@ class _PulsingDotState extends State<_PulsingDot> class _RunningTextWidget extends StatefulWidget { final List texts; final List durations; // per-item seconds - final String animType; // 'marquee' or 'fade' + final String animType; // 'marquee' or 'fade' final TextStyle style; const _RunningTextWidget({ @@ -698,34 +928,34 @@ class _RunningTextWidgetState extends State<_RunningTextWidget> void _startCycle() async { try { - while (!_disposed) { - if (widget.texts.isEmpty) { - await Future.delayed(const Duration(seconds: 1)); - continue; - } - final dur = widget.durations[_index]; + while (!_disposed) { + if (widget.texts.isEmpty) { + await Future.delayed(const Duration(seconds: 1)); + continue; + } + final dur = widget.durations[_index]; - if (widget.animType == 'fade') { - if (_disposed) break; - await _fadeCtrl.forward().orCancel; - if (_disposed) break; - await Future.delayed(Duration(seconds: dur)); - if (_disposed) break; - await _fadeCtrl.reverse().orCancel; - } else { - if (_disposed) break; - _scrollCtrl.duration = Duration(seconds: dur); - _scrollCtrl.reset(); - await _scrollCtrl.forward().orCancel; - } + if (widget.animType == 'fade') { + if (_disposed) break; + await _fadeCtrl.forward().orCancel; + if (_disposed) break; + await Future.delayed(Duration(seconds: dur)); + if (_disposed) break; + await _fadeCtrl.reverse().orCancel; + } else { + if (_disposed) break; + _scrollCtrl.duration = Duration(seconds: dur); + _scrollCtrl.reset(); + await _scrollCtrl.forward().orCancel; + } - if (_disposed) break; - if (mounted) { - setState(() { - _index = (_index + 1) % widget.texts.length; - }); + if (_disposed) break; + if (mounted) { + setState(() { + _index = (_index + 1) % widget.texts.length; + }); + } } - } } on TickerCanceled { // Widget disposed while animation was running — exit cleanly } catch (e) { @@ -741,7 +971,6 @@ class _RunningTextWidgetState extends State<_RunningTextWidget> super.dispose(); } - @override Widget build(BuildContext context) { final text = widget.texts[_index]; @@ -753,8 +982,7 @@ class _RunningTextWidgetState extends State<_RunningTextWidget> child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.info_outline, - color: SacredColors.secondary, size: 16), + Icon(Icons.info_outline, color: SacredColors.secondary, size: 16), const SizedBox(width: 8), Text(text, style: widget.style, maxLines: 1), ], @@ -781,7 +1009,8 @@ class _RunningTextWidgetState extends State<_RunningTextWidget> const SizedBox(width: 8), Text(text, style: widget.style, maxLines: 1), const SizedBox(width: 80), - Icon(Icons.circle, color: SacredColors.secondary.withValues(alpha: 0.4), size: 6), + Icon(Icons.circle, + color: SacredColors.secondary.withValues(alpha: 0.4), size: 6), const SizedBox(width: 80), ], ), diff --git a/lib/providers.dart b/lib/providers.dart index 4628ce2..10abb40 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -59,10 +59,13 @@ class SettingsNotifier extends StateNotifier { final textScaleProvider = Provider((ref) { final index = ref.watch(settingsProvider.select((s) => s.textScaleIndex)); switch (index) { - case 0: return 0.85; // Small - case 2: return 1.15; // Large + case 0: + return 0.85; // Small + case 2: + return 1.15; // Large case 1: - default: return 1.0; // Medium + default: + return 1.0; // Medium } }); @@ -103,11 +106,11 @@ final hijriDateProvider = FutureProvider((ref) async { /// Computed state that tells the UI which screen to display. class ScreenStateData { final ScreenState state; - final PrayerName? activePrayer; // Current or next prayer + final PrayerName? activePrayer; // Current or next prayer final PrayerName? nextPrayer; - final Duration? timeUntilNext; // Countdown to next prayer time - final Duration? iqomahRemaining; // Countdown during iqomah state - final Duration? blankRemaining; // Countdown during shalat/blank state + final Duration? timeUntilNext; // Countdown to next prayer time + final Duration? iqomahRemaining; // Countdown during iqomah state + final Duration? blankRemaining; // Countdown during shalat/blank state final bool isFriday; final DateTime now; @@ -151,12 +154,18 @@ final screenStateProvider = Provider((ref) { int iqomahMinutes(PrayerName p) { switch (p) { - case PrayerName.subuh: return settings.iqomahSubuh; - case PrayerName.dzuhur: return settings.iqomahDzuhur; - case PrayerName.ashar: return settings.iqomahAshar; - case PrayerName.maghrib: return settings.iqomahMaghrib; - case PrayerName.isya: return settings.iqomahIsya; - default: return 10; + case PrayerName.subuh: + return settings.iqomahSubuh; + case PrayerName.dzuhur: + return settings.iqomahDzuhur; + case PrayerName.ashar: + return settings.iqomahAshar; + case PrayerName.maghrib: + return settings.iqomahMaghrib; + case PrayerName.isya: + return settings.iqomahIsya; + default: + return 10; } } @@ -172,8 +181,7 @@ final screenStateProvider = Provider((ref) { adzanTime.subtract(Duration(minutes: settings.preAdzanLead)); final iqomahDuration = Duration(minutes: iqomahMinutes(prayer.key)); final iqomahEnd = adzanTime.add(iqomahDuration); - final blankEnd = - iqomahEnd.add(Duration(minutes: blankMinutes())); + final blankEnd = iqomahEnd.add(Duration(minutes: blankMinutes())); // STATE: SHALAT (Black Screen) if (clock.isAfter(iqomahEnd) && clock.isBefore(blankEnd)) { @@ -246,6 +254,9 @@ final screenStateProvider = Provider((ref) { // ROTATION PROVIDER (for Normal state slideshow) // ────────────────────────────────────────────── +/// Elapsed seconds in the active rotation phase (main/slideshow). +final rotationElapsedProvider = StateProvider((ref) => 0); + /// Controls the rotation between main screen and slideshow views. final rotationIndexProvider = StateNotifierProvider((ref) { @@ -264,36 +275,59 @@ class RotationNotifier extends StateNotifier { void _startRotation() { _timer?.cancel(); _elapsed = 0; + _ref.read(rotationElapsedProvider.notifier).state = 0; _timer = Timer.periodic(const Duration(seconds: 1), (_) { final screenState = _ref.read(screenStateProvider); if (screenState.state != ScreenState.normal) { // Don't rotate during special states, reset elapsed _elapsed = 0; + _ref.read(rotationElapsedProvider.notifier).state = 0; return; } - _elapsed++; final settings = _ref.read(settingsProvider); - final validSlides = settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList(); + final validSlides = + settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList(); final hasContent = validSlides.isNotEmpty; + + _elapsed++; if (!hasContent) { - _elapsed = 0; + final duration = _resolveMainPhaseDuration(settings); + if (_elapsed >= duration) { + _elapsed = 0; + } + _ref.read(rotationElapsedProvider.notifier).state = _elapsed; if (state != 0) state = 0; // force main screen state return; } final isMainScreen = state % 2 == 0; final duration = isMainScreen - ? settings.mainScreenDurationSec - : settings.slideDurationSec; + ? _resolveMainPhaseDuration(settings) + : settings.slideDurationSec.clamp(1, 600); if (_elapsed >= duration) { _elapsed = 0; state = state + 1; } + _ref.read(rotationElapsedProvider.notifier).state = _elapsed; }); } + int _resolveMainPhaseDuration(AppSettings settings) { + final centerSlides = settings.textSlides + .map((text) => text.trim()) + .where((text) => text.isNotEmpty) + .toList(); + if (centerSlides.isEmpty) { + return settings.mainScreenDurationSec.clamp(1, 600); + } + + final heroDuration = settings.mainCenterSlideDurationSec.clamp(1, 600); + final perAnnouncement = settings.announcementSlideDurationSec.clamp(1, 600); + return heroDuration + (perAnnouncement * centerSlides.length); + } + @override void dispose() { _timer?.cancel(); @@ -306,11 +340,14 @@ class RotationNotifier extends StateNotifier { /// Unsplash background is disabled — no point rotating to an empty slide. final isMainScreenProvider = Provider((ref) { final settings = ref.watch(settingsProvider); - final validSlides = settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList(); + // Keep rotation notifier alive even when slideshow media is empty, + // because main-screen text slides depend on rotation elapsed time. + final index = ref.watch(rotationIndexProvider); + final validSlides = + settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList(); final hasContent = validSlides.isNotEmpty; if (!hasContent) return true; // always stay on main screen - final index = ref.watch(rotationIndexProvider); // Even = main, Odd = slideshow return index % 2 == 0; }); diff --git a/pubspec.yaml b/pubspec.yaml index fb6acac..7e11e21 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: jamshalat_masjid_screen description: Smart Digital Prayer Clock for Android TV Box publish_to: 'none' -version: 1.0.4+5 +version: 1.0.5+6 environment: sdk: '>=3.0.0 <4.0.0'