feat(tv-ui): add slideshow pattern mode and improve admin readability

This commit is contained in:
dwindown
2026-04-06 09:23:42 +07:00
parent 185c55a143
commit 414450125d
6 changed files with 312 additions and 34 deletions

View File

@@ -8,6 +8,16 @@ class HiveBoxes {
static const String hijriCache = 'hijri_cache'; static const String hijriCache = 'hijri_cache';
} }
class SlideshowPatternMode {
SlideshowPatternMode._();
static const String alternating = 'alternating';
static const String burst = 'burst';
static bool isValid(String value) =>
value == alternating || value == burst;
}
/// AppSettings stored in Hive. /// AppSettings stored in Hive.
@HiveType(typeId: 0) @HiveType(typeId: 0)
class AppSettings extends HiveObject { class AppSettings extends HiveObject {
@@ -94,6 +104,16 @@ class AppSettings extends HiveObject {
@HiveField(34) @HiveField(34)
int announcementSlideDurationSec; int announcementSlideDurationSec;
// Slideshow pattern mode:
// - alternating: main-1-main-2-main...
// - burst: main-1-2-main-3-4... (N slides between main phases)
@HiveField(38)
String slideshowPatternMode;
// Number of slideshow slides shown between main phases when mode=burst.
@HiveField(39)
int slideshowSlidesPerMain;
// Slideshow image paths (local) // Slideshow image paths (local)
@HiveField(20) @HiveField(20)
List<String> slideshowImages; List<String> slideshowImages;
@@ -180,6 +200,8 @@ class AppSettings extends HiveObject {
this.lastAutoSyncAttemptDate, this.lastAutoSyncAttemptDate,
this.mainCenterSlideDurationSec = 10, this.mainCenterSlideDurationSec = 10,
this.announcementSlideDurationSec = 7, this.announcementSlideDurationSec = 7,
this.slideshowPatternMode = SlideshowPatternMode.alternating,
this.slideshowSlidesPerMain = 2,
this.slideshowImages = const [], this.slideshowImages = const [],
this.textScaleIndex = 1, this.textScaleIndex = 1,
this.useUnsplashBackground = false, this.useUnsplashBackground = false,
@@ -221,6 +243,8 @@ class AppSettings extends HiveObject {
String? lastAutoSyncAttemptDate, String? lastAutoSyncAttemptDate,
int? mainCenterSlideDurationSec, int? mainCenterSlideDurationSec,
int? announcementSlideDurationSec, int? announcementSlideDurationSec,
String? slideshowPatternMode,
int? slideshowSlidesPerMain,
List<String>? slideshowImages, List<String>? slideshowImages,
int? textScaleIndex, int? textScaleIndex,
bool? useUnsplashBackground, bool? useUnsplashBackground,
@@ -265,6 +289,10 @@ class AppSettings extends HiveObject {
mainCenterSlideDurationSec ?? this.mainCenterSlideDurationSec, mainCenterSlideDurationSec ?? this.mainCenterSlideDurationSec,
announcementSlideDurationSec: announcementSlideDurationSec:
announcementSlideDurationSec ?? this.announcementSlideDurationSec, announcementSlideDurationSec ?? this.announcementSlideDurationSec,
slideshowPatternMode:
slideshowPatternMode ?? this.slideshowPatternMode,
slideshowSlidesPerMain:
slideshowSlidesPerMain ?? this.slideshowSlidesPerMain,
slideshowImages: slideshowImages ?? this.slideshowImages, slideshowImages: slideshowImages ?? this.slideshowImages,
textScaleIndex: textScaleIndex ?? this.textScaleIndex, textScaleIndex: textScaleIndex ?? this.textScaleIndex,
useUnsplashBackground: useUnsplashBackground:
@@ -299,6 +327,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
fields[reader.readByte()] = reader.read(); fields[reader.readByte()] = reader.read();
} }
final runningTexts = (fields[14] as List?)?.cast<String>() ?? const []; final runningTexts = (fields[14] as List?)?.cast<String>() ?? const [];
final storedPatternMode = (fields[38] as String?)?.trim() ?? '';
return AppSettings( return AppSettings(
masjidName: fields[0] as String? ?? 'Masjid Al-Ikhlas', masjidName: fields[0] as String? ?? 'Masjid Al-Ikhlas',
masjidAddress: fields[1] as String? ?? 'Jl. Kebaikan No. 1', masjidAddress: fields[1] as String? ?? 'Jl. Kebaikan No. 1',
@@ -325,6 +354,11 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
lastAutoSyncAttemptDate: fields[32] as String?, lastAutoSyncAttemptDate: fields[32] as String?,
mainCenterSlideDurationSec: fields[33] as int? ?? 10, mainCenterSlideDurationSec: fields[33] as int? ?? 10,
announcementSlideDurationSec: fields[34] as int? ?? 7, announcementSlideDurationSec: fields[34] as int? ?? 7,
slideshowPatternMode: SlideshowPatternMode.isValid(storedPatternMode)
? storedPatternMode
: SlideshowPatternMode.alternating,
slideshowSlidesPerMain:
((fields[39] as int?) ?? 2).clamp(1, 20).toInt(),
slideshowImages: (fields[20] as List?)?.cast<String>() ?? const [], slideshowImages: (fields[20] as List?)?.cast<String>() ?? const [],
textScaleIndex: fields[21] as int? ?? 1, textScaleIndex: fields[21] as int? ?? 1,
useUnsplashBackground: fields[22] as bool? ?? false, useUnsplashBackground: fields[22] as bool? ?? false,
@@ -345,7 +379,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override @override
void write(BinaryWriter writer, AppSettings obj) { void write(BinaryWriter writer, AppSettings obj) {
writer writer
..writeByte(38) ..writeByte(40)
..writeByte(0) ..writeByte(0)
..write(obj.masjidName) ..write(obj.masjidName)
..writeByte(1) ..writeByte(1)
@@ -394,6 +428,10 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
..write(obj.mainCenterSlideDurationSec) ..write(obj.mainCenterSlideDurationSec)
..writeByte(34) ..writeByte(34)
..write(obj.announcementSlideDurationSec) ..write(obj.announcementSlideDurationSec)
..writeByte(38)
..write(obj.slideshowPatternMode)
..writeByte(39)
..write(obj.slideshowSlidesPerMain)
..writeByte(20) ..writeByte(20)
..write(obj.slideshowImages) ..write(obj.slideshowImages)
..writeByte(21) ..writeByte(21)

View File

@@ -9,6 +9,7 @@ import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../../core/sacred_tokens.dart'; import '../../core/sacred_tokens.dart';
import '../../data/local/models.dart';
import '../../providers.dart'; import '../../providers.dart';
import '../../data/services/sync_service.dart'; import '../../data/services/sync_service.dart';
import '../../data/services/myquran_service.dart'; import '../../data/services/myquran_service.dart';
@@ -39,12 +40,14 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
final _mainDurCtrl = TextEditingController(); final _mainDurCtrl = TextEditingController();
final _slideDurCtrl = TextEditingController(); final _slideDurCtrl = TextEditingController();
final _slidesPerMainCtrl = TextEditingController();
final _mainHeroDurCtrl = TextEditingController(); final _mainHeroDurCtrl = TextEditingController();
final _textSlideDurCtrl = TextEditingController(); final _textSlideDurCtrl = TextEditingController();
int _selectedTab = 0; int _selectedTab = 0;
bool _isSyncing = false; bool _isSyncing = false;
int _textScaleIndex = 1; int _textScaleIndex = 1;
String _slideshowPatternMode = SlideshowPatternMode.alternating;
List<String> _slideshowImages = []; List<String> _slideshowImages = [];
bool _useUnsplash = false; bool _useUnsplash = false;
final _unsplashKeywordCtrl = TextEditingController(); final _unsplashKeywordCtrl = TextEditingController();
@@ -162,9 +165,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_cityCtrl.text = '${settings.cityDisplayName} (${settings.cityIdApi})'; _cityCtrl.text = '${settings.cityDisplayName} (${settings.cityIdApi})';
_mainDurCtrl.text = settings.mainScreenDurationSec.toString(); _mainDurCtrl.text = settings.mainScreenDurationSec.toString();
_slideDurCtrl.text = settings.slideDurationSec.toString(); _slideDurCtrl.text = settings.slideDurationSec.toString();
_slidesPerMainCtrl.text = settings.slideshowSlidesPerMain.toString();
_mainHeroDurCtrl.text = settings.mainCenterSlideDurationSec.toString(); _mainHeroDurCtrl.text = settings.mainCenterSlideDurationSec.toString();
_textSlideDurCtrl.text = settings.announcementSlideDurationSec.toString(); _textSlideDurCtrl.text = settings.announcementSlideDurationSec.toString();
_textScaleIndex = settings.textScaleIndex; _textScaleIndex = settings.textScaleIndex;
_slideshowPatternMode = settings.slideshowPatternMode;
_slideshowImages = List.from(settings.slideshowImages); _slideshowImages = List.from(settings.slideshowImages);
_useUnsplash = settings.useUnsplashBackground; _useUnsplash = settings.useUnsplashBackground;
_unsplashKeywordCtrl.text = settings.unsplashKeyword; _unsplashKeywordCtrl.text = settings.unsplashKeyword;
@@ -202,6 +207,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_mainDurCtrl.addListener(_queueTampilanAutoSave); _mainDurCtrl.addListener(_queueTampilanAutoSave);
_slideDurCtrl.addListener(_queueTampilanAutoSave); _slideDurCtrl.addListener(_queueTampilanAutoSave);
_slidesPerMainCtrl.addListener(_queueTampilanAutoSave);
_mainHeroDurCtrl.addListener(_queuePengumumanAutoSave); _mainHeroDurCtrl.addListener(_queuePengumumanAutoSave);
_textSlideDurCtrl.addListener(_queuePengumumanAutoSave); _textSlideDurCtrl.addListener(_queuePengumumanAutoSave);
_unsplashKeywordCtrl.addListener(_queueTampilanAutoSave); _unsplashKeywordCtrl.addListener(_queueTampilanAutoSave);
@@ -236,6 +242,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_cityCtrl.dispose(); _cityCtrl.dispose();
_mainDurCtrl.dispose(); _mainDurCtrl.dispose();
_slideDurCtrl.dispose(); _slideDurCtrl.dispose();
_slidesPerMainCtrl.dispose();
_mainHeroDurCtrl.dispose(); _mainHeroDurCtrl.dispose();
_textSlideDurCtrl.dispose(); _textSlideDurCtrl.dispose();
_unsplashKeywordCtrl.dispose(); _unsplashKeywordCtrl.dispose();
@@ -322,6 +329,13 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
s.slideshowImages = List.from(_slideshowImages); s.slideshowImages = List.from(_slideshowImages);
s.mainScreenDurationSec = int.tryParse(_mainDurCtrl.text.trim()) ?? 15; s.mainScreenDurationSec = int.tryParse(_mainDurCtrl.text.trim()) ?? 15;
s.slideDurationSec = int.tryParse(_slideDurCtrl.text.trim()) ?? 10; s.slideDurationSec = int.tryParse(_slideDurCtrl.text.trim()) ?? 10;
s.slideshowPatternMode = SlideshowPatternMode.isValid(_slideshowPatternMode)
? _slideshowPatternMode
: SlideshowPatternMode.alternating;
s.slideshowSlidesPerMain =
(int.tryParse(_slidesPerMainCtrl.text.trim()) ?? 2)
.clamp(1, 20)
.toInt();
s.useUnsplashBackground = _useUnsplash; s.useUnsplashBackground = _useUnsplash;
s.unsplashKeyword = _unsplashKeywordCtrl.text.trim().isEmpty ? 'mosque' : _unsplashKeywordCtrl.text.trim(); s.unsplashKeyword = _unsplashKeywordCtrl.text.trim().isEmpty ? 'mosque' : _unsplashKeywordCtrl.text.trim();
s.unsplashRotationHours = int.tryParse(_unsplashRotationCtrl.text.trim()) ?? 6; s.unsplashRotationHours = int.tryParse(_unsplashRotationCtrl.text.trim()) ?? 6;
@@ -1390,15 +1404,16 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
int _tampilanRowCount() { int _tampilanRowCount() {
var count = 0; var count = 0;
count += 8; count += 11;
if (_slideshowPatternMode == SlideshowPatternMode.burst) {
count += 1;
}
if (_useUnsplash) { if (_useUnsplash) {
count += 2; count += 2;
} }
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) { if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) {
count += 1; count += 1;
} }
count += 1;
count += 1;
count += _slideshowImages.length; count += _slideshowImages.length;
return count; return count;
} }
@@ -1844,7 +1859,15 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
children: [ children: [
Text(title, style: GoogleFonts.manrope(fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)), Text(title, style: GoogleFonts.manrope(fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
SizedBox(height: 4 * s), SizedBox(height: 4 * s),
Text(desc, style: GoogleFonts.manrope(fontSize: 13 * s, color: SacredColors.onSurfaceVariant)), Text(
desc,
style: GoogleFonts.manrope(
fontSize: 15 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
height: 1.35,
),
),
], ],
), ),
), ),
@@ -1857,6 +1880,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
final textScaleRow = row++; final textScaleRow = row++;
final mainDurationRow = row++; final mainDurationRow = row++;
final slideDurationRow = row++; final slideDurationRow = row++;
final slideshowPatternRow = row++;
int? slidesPerMainRow;
if (_slideshowPatternMode == SlideshowPatternMode.burst) {
slidesPerMainRow = row++;
}
final scaleLabelRow = row++; final scaleLabelRow = row++;
final scaleBodyRow = row++; final scaleBodyRow = row++;
final scaleRunningRow = row++; final scaleRunningRow = row++;
@@ -1942,8 +1970,49 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
suffix: 'detik', suffix: 'detik',
onMoveLeft: () => _focusNavTab(_selectedTab), onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusTampilanRow(mainDurationRow), onMoveUp: () => _focusTampilanRow(mainDurationRow),
onMoveDown: () => _focusTampilanRow(scaleLabelRow), onMoveDown: () => _focusTampilanRow(slideshowPatternRow),
), ),
SizedBox(height: 24 * s),
_buildTvChoiceField(
s: s,
rowIndex: slideshowPatternRow,
label: 'Pola Rotasi Slideshow',
options: const ['Main-1-Main', 'Main-N-Main'],
selectedIndex:
_slideshowPatternMode == SlideshowPatternMode.burst
? 1
: 0,
onChanged: (index) {
setState(() {
_slideshowPatternMode = index == 1
? SlideshowPatternMode.burst
: SlideshowPatternMode.alternating;
if (_slideshowPatternMode == SlideshowPatternMode.burst &&
_slidesPerMainCtrl.text.trim().isEmpty) {
_slidesPerMainCtrl.text = '2';
}
});
_queueTampilanAutoSave(
message: 'Pola slideshow otomatis tersimpan',
);
},
),
if (_slideshowPatternMode == SlideshowPatternMode.burst) ...[
SizedBox(height: 16 * s),
_buildTvIntStepperField(
s: s,
label: 'Jumlah Slide antar Main',
focusNode: _tampilanFocusNode(slidesPerMainRow!),
controller: _slidesPerMainCtrl,
fallback: 2,
min: 1,
max: 20,
suffix: 'slide',
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusTampilanRow(slideshowPatternRow),
onMoveDown: () => _focusTampilanRow(scaleLabelRow),
),
],
SizedBox(height: 40 * s), SizedBox(height: 40 * s),
_sectionLabel('Ukuran Teks Per Kelompok', s), _sectionLabel('Ukuran Teks Per Kelompok', s),
SizedBox(height: 8 * s), SizedBox(height: 8 * s),
@@ -1962,7 +2031,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_queueTampilanAutoSave(); _queueTampilanAutoSave();
}, },
onMoveLeft: () => _focusNavTab(_selectedTab), onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusTampilanRow(slideDurationRow), onMoveUp: () => _focusTampilanRow(
slidesPerMainRow ?? slideshowPatternRow,
),
onMoveDown: () => _focusTampilanRow(scaleBodyRow), onMoveDown: () => _focusTampilanRow(scaleBodyRow),
), ),
SizedBox(height: 16 * s), SizedBox(height: 16 * s),
@@ -3375,7 +3446,8 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
return Text( return Text(
label, label,
style: GoogleFonts.plusJakartaSans( style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s, // Match sidebar menu text size for stronger hierarchy consistency.
fontSize: 18 * s,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: SacredColors.primary, color: SacredColors.primary,
), ),
@@ -4249,8 +4321,22 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: GoogleFonts.manrope(fontSize: 12 * s, color: SacredColors.onSurfaceVariant)), Text(
Text(value, style: GoogleFonts.plusJakartaSans(fontSize: 18 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurface)), label,
style: GoogleFonts.manrope(
fontSize: 15 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
Text(
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
], ],
), ),
], ],
@@ -4334,7 +4420,12 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
SizedBox(height: 16 * s), SizedBox(height: 16 * s),
Text( 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).', '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), style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
height: 1.35,
color: SacredColors.onSurfaceVariant,
),
), ),
SizedBox(height: 48 * s), SizedBox(height: 48 * s),
Container( Container(
@@ -4524,7 +4615,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Text( Text(
'Informasi aplikasi, kontak bantuan, dan pemeriksaan versi terbaru.', 'Informasi aplikasi, kontak bantuan, dan pemeriksaan versi terbaru.',
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
fontSize: 18 * s, fontSize: 20 * s,
fontWeight: FontWeight.w500,
height: 1.35,
color: SacredColors.onSurfaceVariant, color: SacredColors.onSurfaceVariant,
), ),
), ),
@@ -4834,7 +4927,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Text( Text(
desc, desc,
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
fontSize: 14 * s, fontSize: 16 * s,
fontWeight: FontWeight.w500,
height: 1.35,
color: SacredColors.onSurfaceVariant, color: SacredColors.onSurfaceVariant,
), ),
), ),
@@ -5175,10 +5270,11 @@ class _TvAdjustTileState extends State<_TvAdjustTile> {
Expanded( Expanded(
child: Text( child: Text(
widget.label, widget.label,
style: GoogleFonts.manrope( style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s, fontSize: 18 * s,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: SacredColors.onSurface, color: SacredColors.onSurfaceVariant,
letterSpacing: 0.4 * s,
), ),
), ),
), ),
@@ -5327,8 +5423,9 @@ class _TvAdjustTileState extends State<_TvAdjustTile> {
? 'Mode ubah aktif. Gunakan ← → lalu tekan OK untuk selesai.' ? 'Mode ubah aktif. Gunakan ← → lalu tekan OK untuk selesai.'
: widget.helperText, : widget.helperText,
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
fontSize: 11 * s, fontSize: 15 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75), fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.88),
), ),
), ),
], ],
@@ -5514,10 +5611,11 @@ class _TvEditableTextTileState extends State<_TvEditableTextTile> {
children: [ children: [
Text( Text(
widget.label, widget.label,
style: GoogleFonts.manrope( style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s, fontSize: 18 * s,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant, color: SacredColors.onSurfaceVariant,
letterSpacing: 0.4 * s,
), ),
), ),
SizedBox(height: 12 * s), SizedBox(height: 12 * s),
@@ -5565,8 +5663,9 @@ class _TvEditableTextTileState extends State<_TvEditableTextTile> {
? 'Mode edit aktif. Tekan ESC untuk selesai.' ? 'Mode edit aktif. Tekan ESC untuk selesai.'
: 'Tekan OK untuk mulai edit.', : 'Tekan OK untuk mulai edit.',
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
fontSize: 11 * s, fontSize: 15 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75), fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.88),
), ),
), ),
], ],

View File

@@ -49,6 +49,10 @@ class _HomeViewState extends ConsumerState<HomeView> {
bool _isAutoRefreshRunning = false; bool _isAutoRefreshRunning = false;
int _touchUnlockTapCount = 0; int _touchUnlockTapCount = 0;
LogicalKeyboardKey _normalizedComboKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.enter ? LogicalKeyboardKey.select : key;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -162,6 +166,13 @@ class _HomeViewState extends ConsumerState<HomeView> {
_recentKeys.removeAt(0); _recentKeys.removeAt(0);
} }
final manualAction = _matchManualRotateSequence();
if (manualAction != null) {
_dispatchManualBackgroundRotate(manualAction);
_resetCombo();
return KeyEventResult.handled;
}
if (_matchesUnlockSequence()) { if (_matchesUnlockSequence()) {
_resetCombo(); _resetCombo();
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -192,14 +203,50 @@ class _HomeViewState extends ConsumerState<HomeView> {
if (_recentKeys.length != _adminUnlockSequence.length) return false; if (_recentKeys.length != _adminUnlockSequence.length) return false;
for (var i = 0; i < _adminUnlockSequence.length; i++) { for (var i = 0; i < _adminUnlockSequence.length; i++) {
final current = _recentKeys[i] == LogicalKeyboardKey.enter final current = _normalizedComboKey(_recentKeys[i]);
? LogicalKeyboardKey.select
: _recentKeys[i];
if (current != _adminUnlockSequence[i]) return false; if (current != _adminUnlockSequence[i]) return false;
} }
return true; return true;
} }
BackgroundRotateAction? _matchManualRotateSequence() {
if (_recentKeys.length < 3) return null;
final tail = _recentKeys.sublist(_recentKeys.length - 3).map(_normalizedComboKey).toList();
if (tail[0] == LogicalKeyboardKey.arrowRight &&
tail[1] == LogicalKeyboardKey.arrowRight &&
tail[2] == LogicalKeyboardKey.select) {
return BackgroundRotateAction.next;
}
if (tail[0] == LogicalKeyboardKey.arrowLeft &&
tail[1] == LogicalKeyboardKey.arrowLeft &&
tail[2] == LogicalKeyboardKey.select) {
return BackgroundRotateAction.previous;
}
if (tail[0] == LogicalKeyboardKey.arrowDown &&
tail[1] == LogicalKeyboardKey.arrowDown &&
tail[2] == LogicalKeyboardKey.select) {
return BackgroundRotateAction.random;
}
return null;
}
void _dispatchManualBackgroundRotate(BackgroundRotateAction action) {
final screenData = ref.read(screenStateProvider);
final isMainScreen = ref.read(isMainScreenProvider);
if (!isMainScreen ||
!(screenData.state == ScreenState.normal ||
screenData.state == ScreenState.menujuAdzan)) {
return;
}
final notifier = ref.read(backgroundRotateCommandProvider.notifier);
final current = notifier.state;
notifier.state = BackgroundRotateCommand(
nonce: current.nonce + 1,
action: action,
);
}
void _resetCombo() { void _resetCombo() {
_comboResetTimer?.cancel(); _comboResetTimer?.cancel();
_recentKeys.clear(); _recentKeys.clear();
@@ -262,6 +309,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
final screenData = ref.watch(screenStateProvider); final screenData = ref.watch(screenStateProvider);
final isMainScreen = ref.watch(isMainScreenProvider); final isMainScreen = ref.watch(isMainScreenProvider);
final rotationIndex = ref.watch(rotationIndexProvider);
// Determine which screen to display // Determine which screen to display
Widget screen; Widget screen;
@@ -273,7 +321,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
} else { } else {
screen = isMainScreen screen = isMainScreen
? const MainScreen(key: ValueKey('main')) ? const MainScreen(key: ValueKey('main'))
: const SlideshowScreen(key: ValueKey('slideshow')); : SlideshowScreen(key: ValueKey('slideshow-$rotationIndex'));
} }
break; break;
case ScreenState.kembaliNormal: case ScreenState.kembaliNormal:

View File

@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/local/models.dart';
import '../../data/services/unsplash_cache_service.dart'; import '../../data/services/unsplash_cache_service.dart';
import '../../providers.dart'; import '../../providers.dart';
@@ -25,6 +26,7 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
String? _lastKeyword; String? _lastKeyword;
int? _lastRotationHours; int? _lastRotationHours;
bool? _lastUseUnsplash; bool? _lastUseUnsplash;
int _lastHandledRotateNonce = 0;
@override @override
void initState() { void initState() {
@@ -106,6 +108,50 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
} }
} }
void _showNextImage() {
if (_imagePaths.length <= 1 || !mounted) return;
setState(() {
_currentIndex = (_currentIndex + 1) % _imagePaths.length;
});
}
void _showPreviousImage() {
if (_imagePaths.length <= 1 || !mounted) return;
setState(() {
_currentIndex = (_currentIndex - 1 + _imagePaths.length) % _imagePaths.length;
});
}
Future<void> _handleManualRotate(
BackgroundRotateAction action,
AppSettings settings,
) async {
if (!settings.useUnsplashBackground) return;
if (_imagePaths.isEmpty) {
final cachedPaths = await UnsplashCacheService.instance.getCachedImagePaths(
settings.unsplashKeyword,
);
if (!mounted) return;
if (cachedPaths.isNotEmpty) {
_applyImagePaths(cachedPaths);
}
}
if (_imagePaths.isEmpty) return;
switch (action) {
case BackgroundRotateAction.next:
_showNextImage();
break;
case BackgroundRotateAction.previous:
_showPreviousImage();
break;
case BackgroundRotateAction.random:
_nextRandomImage();
break;
}
}
void _startTimer(int hours) { void _startTimer(int hours) {
_rotationTimer?.cancel(); _rotationTimer?.cancel();
if (hours <= 0) return; if (hours <= 0) return;
@@ -125,6 +171,7 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final rotateCommand = ref.watch(backgroundRotateCommandProvider);
// Watch for config changes // Watch for config changes
if (settings.useUnsplashBackground != _lastUseUnsplash) { if (settings.useUnsplashBackground != _lastUseUnsplash) {
@@ -146,6 +193,14 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
_startTimer(settings.unsplashRotationHours); _startTimer(settings.unsplashRotationHours);
} }
if (rotateCommand.nonce != _lastHandledRotateNonce) {
_lastHandledRotateNonce = rotateCommand.nonce;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_handleManualRotate(rotateCommand.action, settings);
});
}
if (!settings.useUnsplashBackground || _imagePaths.isEmpty) { if (!settings.useUnsplashBackground || _imagePaths.isEmpty) {
return const SizedBox.shrink(); // Fallback to flat background handled underneath return const SizedBox.shrink(); // Fallback to flat background handled underneath
} }

View File

@@ -13,6 +13,27 @@ import 'data/services/sync_service.dart';
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
final mockTimeOffsetProvider = StateProvider<Duration>((ref) => Duration.zero); final mockTimeOffsetProvider = StateProvider<Duration>((ref) => Duration.zero);
enum BackgroundRotateAction { next, previous, random }
class BackgroundRotateCommand {
final int nonce;
final BackgroundRotateAction action;
const BackgroundRotateCommand({
required this.nonce,
required this.action,
});
const BackgroundRotateCommand.initial()
: nonce = 0,
action = BackgroundRotateAction.random;
}
final backgroundRotateCommandProvider =
StateProvider<BackgroundRotateCommand>(
(ref) => const BackgroundRotateCommand.initial(),
);
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// CLOCK PROVIDER — fires every second // CLOCK PROVIDER — fires every second
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -263,6 +284,22 @@ final rotationIndexProvider =
return RotationNotifier(ref); return RotationNotifier(ref);
}); });
bool _isMainPhaseForSettings(
int phaseIndex,
AppSettings settings, {
required bool hasContent,
}) {
if (!hasContent) return true;
if (settings.slideshowPatternMode == SlideshowPatternMode.burst) {
final slidesBetweenMain = settings.slideshowSlidesPerMain.clamp(1, 20);
final cycleLength = slidesBetweenMain + 1; // main + N slides
return phaseIndex % cycleLength == 0;
}
// Default alternating pattern.
return phaseIndex % 2 == 0;
}
class RotationNotifier extends StateNotifier<int> { class RotationNotifier extends StateNotifier<int> {
final Ref _ref; final Ref _ref;
Timer? _timer; Timer? _timer;
@@ -301,7 +338,11 @@ class RotationNotifier extends StateNotifier<int> {
return; return;
} }
final isMainScreen = state % 2 == 0; final isMainScreen = _isMainPhaseForSettings(
state,
settings,
hasContent: hasContent,
);
final duration = isMainScreen final duration = isMainScreen
? _resolveMainPhaseDuration(settings) ? _resolveMainPhaseDuration(settings)
: settings.slideDurationSec.clamp(1, 600); : settings.slideDurationSec.clamp(1, 600);
@@ -346,8 +387,5 @@ final isMainScreenProvider = Provider<bool>((ref) {
final validSlides = final validSlides =
settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList(); settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty; final hasContent = validSlides.isNotEmpty;
if (!hasContent) return true; // always stay on main screen return _isMainPhaseForSettings(index, settings, hasContent: hasContent);
// Even = main, Odd = slideshow
return index % 2 == 0;
}); });

View File

@@ -1,7 +1,7 @@
name: jamshalat_masjid_screen name: jamshalat_masjid_screen
description: Smart Digital Prayer Clock for Android TV Box description: Smart Digital Prayer Clock for Android TV Box
publish_to: 'none' publish_to: 'none'
version: 1.0.13+14 version: 1.0.14+15
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'