feat(tv-ui): add slideshow pattern mode and improve admin readability
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user