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

@@ -9,6 +9,7 @@ import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/sacred_tokens.dart';
import '../../data/local/models.dart';
import '../../providers.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/myquran_service.dart';
@@ -39,12 +40,14 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
final _mainDurCtrl = TextEditingController();
final _slideDurCtrl = TextEditingController();
final _slidesPerMainCtrl = TextEditingController();
final _mainHeroDurCtrl = TextEditingController();
final _textSlideDurCtrl = TextEditingController();
int _selectedTab = 0;
bool _isSyncing = false;
int _textScaleIndex = 1;
String _slideshowPatternMode = SlideshowPatternMode.alternating;
List<String> _slideshowImages = [];
bool _useUnsplash = false;
final _unsplashKeywordCtrl = TextEditingController();
@@ -162,9 +165,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_cityCtrl.text = '${settings.cityDisplayName} (${settings.cityIdApi})';
_mainDurCtrl.text = settings.mainScreenDurationSec.toString();
_slideDurCtrl.text = settings.slideDurationSec.toString();
_slidesPerMainCtrl.text = settings.slideshowSlidesPerMain.toString();
_mainHeroDurCtrl.text = settings.mainCenterSlideDurationSec.toString();
_textSlideDurCtrl.text = settings.announcementSlideDurationSec.toString();
_textScaleIndex = settings.textScaleIndex;
_slideshowPatternMode = settings.slideshowPatternMode;
_slideshowImages = List.from(settings.slideshowImages);
_useUnsplash = settings.useUnsplashBackground;
_unsplashKeywordCtrl.text = settings.unsplashKeyword;
@@ -202,6 +207,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_mainDurCtrl.addListener(_queueTampilanAutoSave);
_slideDurCtrl.addListener(_queueTampilanAutoSave);
_slidesPerMainCtrl.addListener(_queueTampilanAutoSave);
_mainHeroDurCtrl.addListener(_queuePengumumanAutoSave);
_textSlideDurCtrl.addListener(_queuePengumumanAutoSave);
_unsplashKeywordCtrl.addListener(_queueTampilanAutoSave);
@@ -236,6 +242,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_cityCtrl.dispose();
_mainDurCtrl.dispose();
_slideDurCtrl.dispose();
_slidesPerMainCtrl.dispose();
_mainHeroDurCtrl.dispose();
_textSlideDurCtrl.dispose();
_unsplashKeywordCtrl.dispose();
@@ -322,6 +329,13 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
s.slideshowImages = List.from(_slideshowImages);
s.mainScreenDurationSec = int.tryParse(_mainDurCtrl.text.trim()) ?? 15;
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.unsplashKeyword = _unsplashKeywordCtrl.text.trim().isEmpty ? 'mosque' : _unsplashKeywordCtrl.text.trim();
s.unsplashRotationHours = int.tryParse(_unsplashRotationCtrl.text.trim()) ?? 6;
@@ -1390,15 +1404,16 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
int _tampilanRowCount() {
var count = 0;
count += 8;
count += 11;
if (_slideshowPatternMode == SlideshowPatternMode.burst) {
count += 1;
}
if (_useUnsplash) {
count += 2;
}
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) {
count += 1;
}
count += 1;
count += 1;
count += _slideshowImages.length;
return count;
}
@@ -1844,7 +1859,15 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
children: [
Text(title, style: GoogleFonts.manrope(fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
SizedBox(height: 4 * s),
Text(desc, style: GoogleFonts.manrope(fontSize: 13 * s, color: SacredColors.onSurfaceVariant)),
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 mainDurationRow = row++;
final slideDurationRow = row++;
final slideshowPatternRow = row++;
int? slidesPerMainRow;
if (_slideshowPatternMode == SlideshowPatternMode.burst) {
slidesPerMainRow = row++;
}
final scaleLabelRow = row++;
final scaleBodyRow = row++;
final scaleRunningRow = row++;
@@ -1942,8 +1970,49 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
suffix: 'detik',
onMoveLeft: () => _focusNavTab(_selectedTab),
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),
_sectionLabel('Ukuran Teks Per Kelompok', s),
SizedBox(height: 8 * s),
@@ -1962,7 +2031,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_queueTampilanAutoSave();
},
onMoveLeft: () => _focusNavTab(_selectedTab),
onMoveUp: () => _focusTampilanRow(slideDurationRow),
onMoveUp: () => _focusTampilanRow(
slidesPerMainRow ?? slideshowPatternRow,
),
onMoveDown: () => _focusTampilanRow(scaleBodyRow),
),
SizedBox(height: 16 * s),
@@ -3375,7 +3446,8 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
return Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
// Match sidebar menu text size for stronger hierarchy consistency.
fontSize: 18 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
@@ -4249,8 +4321,22 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: GoogleFonts.manrope(fontSize: 12 * s, color: SacredColors.onSurfaceVariant)),
Text(value, style: GoogleFonts.plusJakartaSans(fontSize: 18 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurface)),
Text(
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),
Text(
'Gunakan tombol di bawah ini untuk melihat pratinjau bagaimana aplikasi bereaksi terhadap berbagai waktu dan status tanpa harus menunggu waktu sebenarnya.\nFitur ini bekerja dengan menggeser waktu aplikasi (Time Travel).',
style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.onSurfaceVariant),
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
height: 1.35,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 48 * s),
Container(
@@ -4524,7 +4615,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Text(
'Informasi aplikasi, kontak bantuan, dan pemeriksaan versi terbaru.',
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontSize: 20 * s,
fontWeight: FontWeight.w500,
height: 1.35,
color: SacredColors.onSurfaceVariant,
),
),
@@ -4834,7 +4927,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
Text(
desc,
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontSize: 16 * s,
fontWeight: FontWeight.w500,
height: 1.35,
color: SacredColors.onSurfaceVariant,
),
),
@@ -5175,10 +5270,11 @@ class _TvAdjustTileState extends State<_TvAdjustTile> {
Expanded(
child: Text(
widget.label,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
style: GoogleFonts.plusJakartaSans(
fontSize: 18 * s,
fontWeight: FontWeight.w700,
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.'
: widget.helperText,
style: GoogleFonts.manrope(
fontSize: 11 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75),
fontSize: 15 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.88),
),
),
],
@@ -5514,10 +5611,11 @@ class _TvEditableTextTileState extends State<_TvEditableTextTile> {
children: [
Text(
widget.label,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
style: GoogleFonts.plusJakartaSans(
fontSize: 18 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 0.4 * s,
),
),
SizedBox(height: 12 * s),
@@ -5565,8 +5663,9 @@ class _TvEditableTextTileState extends State<_TvEditableTextTile> {
? 'Mode edit aktif. Tekan ESC untuk selesai.'
: 'Tekan OK untuk mulai edit.',
style: GoogleFonts.manrope(
fontSize: 11 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.75),
fontSize: 15 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.88),
),
),
],

View File

@@ -49,6 +49,10 @@ class _HomeViewState extends ConsumerState<HomeView> {
bool _isAutoRefreshRunning = false;
int _touchUnlockTapCount = 0;
LogicalKeyboardKey _normalizedComboKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.enter ? LogicalKeyboardKey.select : key;
}
@override
void initState() {
super.initState();
@@ -162,6 +166,13 @@ class _HomeViewState extends ConsumerState<HomeView> {
_recentKeys.removeAt(0);
}
final manualAction = _matchManualRotateSequence();
if (manualAction != null) {
_dispatchManualBackgroundRotate(manualAction);
_resetCombo();
return KeyEventResult.handled;
}
if (_matchesUnlockSequence()) {
_resetCombo();
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -192,14 +203,50 @@ class _HomeViewState extends ConsumerState<HomeView> {
if (_recentKeys.length != _adminUnlockSequence.length) return false;
for (var i = 0; i < _adminUnlockSequence.length; i++) {
final current = _recentKeys[i] == LogicalKeyboardKey.enter
? LogicalKeyboardKey.select
: _recentKeys[i];
final current = _normalizedComboKey(_recentKeys[i]);
if (current != _adminUnlockSequence[i]) return false;
}
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() {
_comboResetTimer?.cancel();
_recentKeys.clear();
@@ -262,6 +309,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
final screenData = ref.watch(screenStateProvider);
final isMainScreen = ref.watch(isMainScreenProvider);
final rotationIndex = ref.watch(rotationIndexProvider);
// Determine which screen to display
Widget screen;
@@ -273,7 +321,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
} else {
screen = isMainScreen
? const MainScreen(key: ValueKey('main'))
: const SlideshowScreen(key: ValueKey('slideshow'));
: SlideshowScreen(key: ValueKey('slideshow-$rotationIndex'));
}
break;
case ScreenState.kembaliNormal:

View File

@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/local/models.dart';
import '../../data/services/unsplash_cache_service.dart';
import '../../providers.dart';
@@ -25,6 +26,7 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
String? _lastKeyword;
int? _lastRotationHours;
bool? _lastUseUnsplash;
int _lastHandledRotateNonce = 0;
@override
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) {
_rotationTimer?.cancel();
if (hours <= 0) return;
@@ -125,6 +171,7 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final rotateCommand = ref.watch(backgroundRotateCommandProvider);
// Watch for config changes
if (settings.useUnsplashBackground != _lastUseUnsplash) {
@@ -146,6 +193,14 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
_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) {
return const SizedBox.shrink(); // Fallback to flat background handled underneath
}