feat(tv-ui): split pengumuman tab and refine main text-slide behavior

This commit is contained in:
dwindown
2026-04-03 22:03:18 +07:00
parent 14c3850092
commit af82418c09
6 changed files with 1523 additions and 517 deletions

View File

@@ -59,6 +59,10 @@ class AppSettings extends HiveObject {
@HiveField(14) @HiveField(14)
List<String> runningTexts; List<String> runningTexts;
// Center text-slide items (separate from running ticker at bottom).
@HiveField(35)
List<String> textSlides;
// Friday officers // Friday officers
@HiveField(15) @HiveField(15)
String khatibName; String khatibName;
@@ -81,6 +85,14 @@ class AppSettings extends HiveObject {
@HiveField(32) @HiveField(32)
String? lastAutoSyncAttemptDate; 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) // Slideshow image paths (local)
@HiveField(20) @HiveField(20)
List<String> slideshowImages; List<String> slideshowImages;
@@ -128,6 +140,14 @@ class AppSettings extends HiveObject {
@HiveField(31) @HiveField(31)
int hijriOffsetDays; 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({ AppSettings({
this.masjidName = 'Masjid Al-Ikhlas', this.masjidName = 'Masjid Al-Ikhlas',
this.masjidAddress = 'Jl. Kebaikan No. 1', this.masjidAddress = 'Jl. Kebaikan No. 1',
@@ -147,12 +167,18 @@ class AppSettings extends HiveObject {
'Mohon luruskan dan rapatkan shaf', 'Mohon luruskan dan rapatkan shaf',
'Kajian rutin setiap Ahad pagi', '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.khatibName = 'Ust. Fulan, S.Ag',
this.imamName = 'Ust. Alan, Lc', this.imamName = 'Ust. Alan, Lc',
this.mainScreenDurationSec = 15, this.mainScreenDurationSec = 15,
this.slideDurationSec = 10, this.slideDurationSec = 10,
this.lastSyncDate, this.lastSyncDate,
this.lastAutoSyncAttemptDate, this.lastAutoSyncAttemptDate,
this.mainCenterSlideDurationSec = 10,
this.announcementSlideDurationSec = 7,
this.slideshowImages = const [], this.slideshowImages = const [],
this.textScaleIndex = 1, this.textScaleIndex = 1,
this.useUnsplashBackground = false, this.useUnsplashBackground = false,
@@ -165,6 +191,8 @@ class AppSettings extends HiveObject {
this.scaleCardBody = 1.0, this.scaleCardBody = 1.0,
this.scaleRunningText = 1.0, this.scaleRunningText = 1.0,
this.hijriOffsetDays = 0, this.hijriOffsetDays = 0,
this.scaleTopHeader = 1.0,
this.scaleTextSlideCenter = 1.0,
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -183,12 +211,15 @@ class AppSettings extends HiveObject {
int? blankScreenNormal, int? blankScreenNormal,
int? blankScreenJumat, int? blankScreenJumat,
List<String>? runningTexts, List<String>? runningTexts,
List<String>? textSlides,
String? khatibName, String? khatibName,
String? imamName, String? imamName,
int? mainScreenDurationSec, int? mainScreenDurationSec,
int? slideDurationSec, int? slideDurationSec,
String? lastSyncDate, String? lastSyncDate,
String? lastAutoSyncAttemptDate, String? lastAutoSyncAttemptDate,
int? mainCenterSlideDurationSec,
int? announcementSlideDurationSec,
List<String>? slideshowImages, List<String>? slideshowImages,
int? textScaleIndex, int? textScaleIndex,
bool? useUnsplashBackground, bool? useUnsplashBackground,
@@ -201,6 +232,8 @@ class AppSettings extends HiveObject {
double? scaleCardBody, double? scaleCardBody,
double? scaleRunningText, double? scaleRunningText,
int? hijriOffsetDays, int? hijriOffsetDays,
double? scaleTopHeader,
double? scaleTextSlideCenter,
}) { }) {
return AppSettings( return AppSettings(
masjidName: masjidName ?? this.masjidName, masjidName: masjidName ?? this.masjidName,
@@ -218,18 +251,26 @@ class AppSettings extends HiveObject {
blankScreenNormal: blankScreenNormal ?? this.blankScreenNormal, blankScreenNormal: blankScreenNormal ?? this.blankScreenNormal,
blankScreenJumat: blankScreenJumat ?? this.blankScreenJumat, blankScreenJumat: blankScreenJumat ?? this.blankScreenJumat,
runningTexts: runningTexts ?? this.runningTexts, runningTexts: runningTexts ?? this.runningTexts,
textSlides: textSlides ?? this.textSlides,
khatibName: khatibName ?? this.khatibName, khatibName: khatibName ?? this.khatibName,
imamName: imamName ?? this.imamName, imamName: imamName ?? this.imamName,
mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec, mainScreenDurationSec:
mainScreenDurationSec ?? this.mainScreenDurationSec,
slideDurationSec: slideDurationSec ?? this.slideDurationSec, slideDurationSec: slideDurationSec ?? this.slideDurationSec,
lastSyncDate: lastSyncDate ?? this.lastSyncDate, lastSyncDate: lastSyncDate ?? this.lastSyncDate,
lastAutoSyncAttemptDate: lastAutoSyncAttemptDate:
lastAutoSyncAttemptDate ?? this.lastAutoSyncAttemptDate, lastAutoSyncAttemptDate ?? this.lastAutoSyncAttemptDate,
mainCenterSlideDurationSec:
mainCenterSlideDurationSec ?? this.mainCenterSlideDurationSec,
announcementSlideDurationSec:
announcementSlideDurationSec ?? this.announcementSlideDurationSec,
slideshowImages: slideshowImages ?? this.slideshowImages, slideshowImages: slideshowImages ?? this.slideshowImages,
textScaleIndex: textScaleIndex ?? this.textScaleIndex, textScaleIndex: textScaleIndex ?? this.textScaleIndex,
useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground, useUnsplashBackground:
useUnsplashBackground ?? this.useUnsplashBackground,
unsplashKeyword: unsplashKeyword ?? this.unsplashKeyword, unsplashKeyword: unsplashKeyword ?? this.unsplashKeyword,
unsplashRotationHours: unsplashRotationHours ?? this.unsplashRotationHours, unsplashRotationHours:
unsplashRotationHours ?? this.unsplashRotationHours,
brandedBgImage: brandedBgImage ?? this.brandedBgImage, brandedBgImage: brandedBgImage ?? this.brandedBgImage,
runningTextDurations: runningTextDurations ?? this.runningTextDurations, runningTextDurations: runningTextDurations ?? this.runningTextDurations,
marqueeAnimType: marqueeAnimType ?? this.marqueeAnimType, marqueeAnimType: marqueeAnimType ?? this.marqueeAnimType,
@@ -237,6 +278,9 @@ class AppSettings extends HiveObject {
scaleCardBody: scaleCardBody ?? this.scaleCardBody, scaleCardBody: scaleCardBody ?? this.scaleCardBody,
scaleRunningText: scaleRunningText ?? this.scaleRunningText, scaleRunningText: scaleRunningText ?? this.scaleRunningText,
hijriOffsetDays: hijriOffsetDays ?? this.hijriOffsetDays, hijriOffsetDays: hijriOffsetDays ?? this.hijriOffsetDays,
scaleTopHeader: scaleTopHeader ?? this.scaleTopHeader,
scaleTextSlideCenter:
scaleTextSlideCenter ?? this.scaleTextSlideCenter,
); );
} }
} }
@@ -253,6 +297,7 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
for (int i = 0; i < numOfFields; i++) { for (int i = 0; i < numOfFields; i++) {
fields[reader.readByte()] = reader.read(); fields[reader.readByte()] = reader.read();
} }
final runningTexts = (fields[14] as List?)?.cast<String>() ?? const [];
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',
@@ -268,13 +313,17 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
preAdzanLead: fields[11] as int? ?? 10, preAdzanLead: fields[11] as int? ?? 10,
blankScreenNormal: fields[12] as int? ?? 15, blankScreenNormal: fields[12] as int? ?? 15,
blankScreenJumat: fields[13] as int? ?? 45, blankScreenJumat: fields[13] as int? ?? 45,
runningTexts: (fields[14] as List?)?.cast<String>() ?? const [], runningTexts: runningTexts,
textSlides:
(fields[35] as List?)?.cast<String>() ?? List<String>.from(runningTexts),
khatibName: fields[15] as String? ?? '', khatibName: fields[15] as String? ?? '',
imamName: fields[16] as String? ?? '', imamName: fields[16] as String? ?? '',
mainScreenDurationSec: fields[17] as int? ?? 15, mainScreenDurationSec: fields[17] as int? ?? 15,
slideDurationSec: fields[18] as int? ?? 10, slideDurationSec: fields[18] as int? ?? 10,
lastSyncDate: fields[19] as String?, lastSyncDate: fields[19] as String?,
lastAutoSyncAttemptDate: fields[32] 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<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,
@@ -287,46 +336,91 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
scaleCardBody: (fields[29] as num?)?.toDouble() ?? 1.0, scaleCardBody: (fields[29] as num?)?.toDouble() ?? 1.0,
scaleRunningText: (fields[30] as num?)?.toDouble() ?? 1.0, scaleRunningText: (fields[30] as num?)?.toDouble() ?? 1.0,
hijriOffsetDays: fields[31] as int? ?? 0, hijriOffsetDays: fields[31] as int? ?? 0,
scaleTopHeader: (fields[36] as num?)?.toDouble() ?? 1.0,
scaleTextSlideCenter: (fields[37] as num?)?.toDouble() ?? 1.0,
); );
} }
@override @override
void write(BinaryWriter writer, AppSettings obj) { void write(BinaryWriter writer, AppSettings obj) {
writer 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(33)
..writeByte(0)..write(obj.masjidName) ..write(obj.mainCenterSlideDurationSec)
..writeByte(1)..write(obj.masjidAddress) ..writeByte(34)
..writeByte(2)..write(obj.cityIdApi) ..write(obj.announcementSlideDurationSec)
..writeByte(3)..write(obj.cityDisplayName) ..writeByte(20)
..writeByte(4)..write(obj.showImsak) ..write(obj.slideshowImages)
..writeByte(5)..write(obj.showTerbit) ..writeByte(21)
..writeByte(6)..write(obj.iqomahSubuh) ..write(obj.textScaleIndex)
..writeByte(7)..write(obj.iqomahDzuhur) ..writeByte(22)
..writeByte(8)..write(obj.iqomahAshar) ..write(obj.useUnsplashBackground)
..writeByte(9)..write(obj.iqomahMaghrib) ..writeByte(23)
..writeByte(10)..write(obj.iqomahIsya) ..write(obj.unsplashKeyword)
..writeByte(11)..write(obj.preAdzanLead) ..writeByte(24)
..writeByte(12)..write(obj.blankScreenNormal) ..write(obj.unsplashRotationHours)
..writeByte(13)..write(obj.blankScreenJumat) ..writeByte(25)
..writeByte(14)..write(obj.runningTexts) ..write(obj.brandedBgImage)
..writeByte(15)..write(obj.khatibName) ..writeByte(26)
..writeByte(16)..write(obj.imamName) ..write(obj.runningTextDurations)
..writeByte(17)..write(obj.mainScreenDurationSec) ..writeByte(27)
..writeByte(18)..write(obj.slideDurationSec) ..write(obj.marqueeAnimType)
..writeByte(19)..write(obj.lastSyncDate) ..writeByte(28)
..writeByte(32)..write(obj.lastAutoSyncAttemptDate) ..write(obj.scaleCardLabel)
..writeByte(20)..write(obj.slideshowImages) ..writeByte(29)
..writeByte(21)..write(obj.textScaleIndex) ..write(obj.scaleCardBody)
..writeByte(22)..write(obj.useUnsplashBackground) ..writeByte(30)
..writeByte(23)..write(obj.unsplashKeyword) ..write(obj.scaleRunningText)
..writeByte(24)..write(obj.unsplashRotationHours) ..writeByte(31)
..writeByte(25)..write(obj.brandedBgImage) ..write(obj.hijriOffsetDays)
..writeByte(26)..write(obj.runningTextDurations) ..writeByte(36)
..writeByte(27)..write(obj.marqueeAnimType) ..write(obj.scaleTopHeader)
..writeByte(28)..write(obj.scaleCardLabel) ..writeByte(37)
..writeByte(29)..write(obj.scaleCardBody) ..write(obj.scaleTextSlideCenter);
..writeByte(30)..write(obj.scaleRunningText)
..writeByte(31)..write(obj.hijriOffsetDays);
} }
} }
@@ -426,14 +520,23 @@ class DailyPrayerScheduleAdapter extends TypeAdapter<DailyPrayerSchedule> {
void write(BinaryWriter writer, DailyPrayerSchedule obj) { void write(BinaryWriter writer, DailyPrayerSchedule obj) {
writer writer
..writeByte(9) ..writeByte(9)
..writeByte(0)..write(obj.date) ..writeByte(0)
..writeByte(1)..write(obj.imsak) ..write(obj.date)
..writeByte(2)..write(obj.subuh) ..writeByte(1)
..writeByte(3)..write(obj.terbit) ..write(obj.imsak)
..writeByte(4)..write(obj.dhuha) ..writeByte(2)
..writeByte(5)..write(obj.dzuhur) ..write(obj.subuh)
..writeByte(6)..write(obj.ashar) ..writeByte(3)
..writeByte(7)..write(obj.maghrib) ..write(obj.terbit)
..writeByte(8)..write(obj.isya); ..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);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -126,7 +126,7 @@ class _HomeViewState extends ConsumerState<HomeView> {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const AdminScreen( builder: (_) => const AdminScreen(
initialTab: 4, initialTab: 5,
focusSelectedTabOnOpen: true, focusSelectedTabOnOpen: true,
), ),
), ),

View File

@@ -38,15 +38,26 @@ class MainScreen extends ConsumerWidget {
final timeStr = DateFormat('HH:mm').format(clock); final timeStr = DateFormat('HH:mm').format(clock);
final secStr = DateFormat(':ss').format(clock); final secStr = DateFormat(':ss').format(clock);
final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'id').format(clock); final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'id').format(clock);
final dateHijri = final dateHijri = ref.watch(hijriDateProvider).valueOrNull ??
ref.watch(hijriDateProvider).valueOrNull ?? HijriDateFormatter.format(clock); 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( return Container(
color: SacredColors.background, color: SacredColors.background,
child: Stack( child: Stack(
children: [ children: [
// ── Underlay 1: Branded local image (highest priority if set) ── // ── 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( Positioned.fill(
child: Image.file( child: Image.file(
File(settings.brandedBgImage!), File(settings.brandedBgImage!),
@@ -100,96 +111,37 @@ class MainScreen extends ConsumerWidget {
child: Column( child: Column(
children: [ children: [
// ── HEADER ── // ── HEADER ──
_buildHeader(context, s, fs, settings, dateGregorian, dateHijri), _buildHeader(
context,
s,
fs,
settings,
dateGregorian,
dateHijri,
inlineClockText:
centerSlide.isPrimary ? null : '$timeStr$secStr',
),
// ── CENTER: Clock + Countdown ── // ── CENTER: Clock + Countdown ──
Expanded( Expanded(
child: Center( child: Center(
child: Column( child: centerSlide.isPrimary
mainAxisSize: MainAxisSize.min, ? _buildPrimaryCenter(
children: [ s,
// Countdown pill fs,
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, 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, 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,
],
),
),
),
SizedBox(height: 16 * s),
// Date line
Text(
dateGregorian, dateGregorian,
style: GoogleFonts.manrope( screenData,
fontSize: 24 * fs, schedule,
fontWeight: FontWeight.w500, settings,
color: SacredColors.onSurfaceVariant, )
letterSpacing: 1 * s, : _buildAnnouncementCenter(
), s,
), fs,
settings,
// Secondary times (Imsak, Terbit, Dhuha) centerSlide,
if (schedule != null) centerTextSlides,
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: _buildSecondaryTimes(s, fs, schedule, settings),
),
// Removed FRIDAY SPECIAL PANEL since its handled by dedicated JumatScreen
],
), ),
), ),
), ),
@@ -213,21 +165,21 @@ class MainScreen extends ConsumerWidget {
); );
} }
Widget _buildHeader( Widget _buildHeader(BuildContext context, double s, double fs,
BuildContext context, AppSettings settings, String dateGregorian, String dateHijri,
double s, {String? inlineClockText}) {
double fs, final hScale = settings.scaleTopHeader;
AppSettings settings, final showInlineClock =
String dateGregorian, inlineClockText != null && inlineClockText.isNotEmpty;
String dateHijri,
) {
return Padding( return Padding(
padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s), padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
// Left: Mosque name + address // Left: Mosque name + address
Padding( Expanded(
flex: 5,
child: Padding(
padding: EdgeInsets.all(8.0 * s), padding: EdgeInsets.all(8.0 * s),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -235,7 +187,7 @@ class MainScreen extends ConsumerWidget {
Text( Text(
settings.masjidName, settings.masjidName,
style: GoogleFonts.plusJakartaSans( style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s, fontSize: 32 * s * hScale,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: SacredColors.primary, color: SacredColors.primary,
letterSpacing: -0.5 * s, letterSpacing: -0.5 * s,
@@ -247,26 +199,67 @@ class MainScreen extends ConsumerWidget {
HugeIcon( HugeIcon(
icon: HugeIcons.strokeRoundedLocation01, icon: HugeIcons.strokeRoundedLocation01,
color: SacredColors.secondary, color: SacredColors.secondary,
size: 16 * s, size: 16 * s * hScale,
), ),
SizedBox(width: 4 * s), SizedBox(width: 4 * s),
Text( Expanded(
child: Text(
settings.masjidAddress, settings.masjidAddress,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
fontSize: 14 * fs, fontSize: 14 * fs * hScale,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.7), color:
SacredColors.onSurface.withValues(alpha: 0.7),
letterSpacing: 0.5 * s, letterSpacing: 0.5 * s,
), ),
), ),
),
], ],
), ),
], ],
), ),
), ),
),
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),
),
),
child: Text(
inlineClockText,
style: GoogleFonts.plusJakartaSans(
fontSize: 30 * s * hScale,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -1 * s,
),
),
),
),
),
// Right: Hijri date + mosque icon // Right: Hijri date + mosque icon
Row( Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@@ -274,7 +267,7 @@ class MainScreen extends ConsumerWidget {
Text( Text(
dateHijri, dateHijri,
style: GoogleFonts.plusJakartaSans( style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s, fontSize: 20 * s * hScale,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: SacredColors.onSurface, color: SacredColors.onSurface,
), ),
@@ -282,7 +275,7 @@ class MainScreen extends ConsumerWidget {
Text( Text(
dateGregorian.toUpperCase(), dateGregorian.toUpperCase(),
style: GoogleFonts.manrope( style: GoogleFonts.manrope(
fontSize: 12 * fs, fontSize: 12 * fs * hScale,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant, color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s, letterSpacing: 2 * s,
@@ -292,8 +285,8 @@ class MainScreen extends ConsumerWidget {
), ),
SizedBox(width: 16 * s), SizedBox(width: 16 * s),
Container( Container(
width: 48 * s, width: 48 * s * hScale,
height: 48 * s, height: 48 * s * hScale,
decoration: BoxDecoration( decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest, color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -301,20 +294,220 @@ class MainScreen extends ConsumerWidget {
child: HugeIcon( child: HugeIcon(
icon: HugeIcons.strokeRoundedHome01, icon: HugeIcons.strokeRoundedHome01,
color: SacredColors.secondary, color: SacredColors.secondary,
size: 28 * s, size: 28 * s * hScale,
), ),
), ),
], ],
), ),
),
),
], ],
), ),
); );
} }
_CenterSlideState _resolveCenterSlide({
required AppSettings settings,
required int elapsedInMainWindowSec,
required List<String> 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<String> 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<Offset>(
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<Color>(SacredColors.primary),
),
),
),
],
),
),
),
),
);
}
Widget _buildCountdownPill(double s, double fs, ScreenStateData screenData) { Widget _buildCountdownPill(double s, double fs, ScreenStateData screenData) {
return Container( return Container(
padding: padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(SacredRadii.full), borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all( border: Border.all(
@@ -393,8 +586,6 @@ class MainScreen extends ConsumerWidget {
); );
} }
Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule, Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule,
ScreenStateData screenData, AppSettings settings, DateTime clock) { ScreenStateData screenData, AppSettings settings, DateTime clock) {
final prayers = [ final prayers = [
@@ -489,6 +680,46 @@ class MainScreen extends ConsumerWidget {
// ─── Supporting widgets ─── // ─── 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 { class _SecondaryTimeItem {
final String label; final String label;
final String time; final String time;
@@ -590,7 +821,6 @@ class _PrayerCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
); );
} }
@@ -741,7 +971,6 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final text = widget.texts[_index]; final text = widget.texts[_index];
@@ -753,8 +982,7 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.info_outline, Icon(Icons.info_outline, color: SacredColors.secondary, size: 16),
color: SacredColors.secondary, size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1), Text(text, style: widget.style, maxLines: 1),
], ],
@@ -781,7 +1009,8 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
const SizedBox(width: 8), const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1), Text(text, style: widget.style, maxLines: 1),
const SizedBox(width: 80), 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), const SizedBox(width: 80),
], ],
), ),

View File

@@ -59,10 +59,13 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
final textScaleProvider = Provider<double>((ref) { final textScaleProvider = Provider<double>((ref) {
final index = ref.watch(settingsProvider.select((s) => s.textScaleIndex)); final index = ref.watch(settingsProvider.select((s) => s.textScaleIndex));
switch (index) { switch (index) {
case 0: return 0.85; // Small case 0:
case 2: return 1.15; // Large return 0.85; // Small
case 2:
return 1.15; // Large
case 1: case 1:
default: return 1.0; // Medium default:
return 1.0; // Medium
} }
}); });
@@ -151,12 +154,18 @@ final screenStateProvider = Provider<ScreenStateData>((ref) {
int iqomahMinutes(PrayerName p) { int iqomahMinutes(PrayerName p) {
switch (p) { switch (p) {
case PrayerName.subuh: return settings.iqomahSubuh; case PrayerName.subuh:
case PrayerName.dzuhur: return settings.iqomahDzuhur; return settings.iqomahSubuh;
case PrayerName.ashar: return settings.iqomahAshar; case PrayerName.dzuhur:
case PrayerName.maghrib: return settings.iqomahMaghrib; return settings.iqomahDzuhur;
case PrayerName.isya: return settings.iqomahIsya; case PrayerName.ashar:
default: return 10; 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<ScreenStateData>((ref) {
adzanTime.subtract(Duration(minutes: settings.preAdzanLead)); adzanTime.subtract(Duration(minutes: settings.preAdzanLead));
final iqomahDuration = Duration(minutes: iqomahMinutes(prayer.key)); final iqomahDuration = Duration(minutes: iqomahMinutes(prayer.key));
final iqomahEnd = adzanTime.add(iqomahDuration); final iqomahEnd = adzanTime.add(iqomahDuration);
final blankEnd = final blankEnd = iqomahEnd.add(Duration(minutes: blankMinutes()));
iqomahEnd.add(Duration(minutes: blankMinutes()));
// STATE: SHALAT (Black Screen) // STATE: SHALAT (Black Screen)
if (clock.isAfter(iqomahEnd) && clock.isBefore(blankEnd)) { if (clock.isAfter(iqomahEnd) && clock.isBefore(blankEnd)) {
@@ -246,6 +254,9 @@ final screenStateProvider = Provider<ScreenStateData>((ref) {
// ROTATION PROVIDER (for Normal state slideshow) // ROTATION PROVIDER (for Normal state slideshow)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
/// Elapsed seconds in the active rotation phase (main/slideshow).
final rotationElapsedProvider = StateProvider<int>((ref) => 0);
/// Controls the rotation between main screen and slideshow views. /// Controls the rotation between main screen and slideshow views.
final rotationIndexProvider = final rotationIndexProvider =
StateNotifierProvider<RotationNotifier, int>((ref) { StateNotifierProvider<RotationNotifier, int>((ref) {
@@ -264,36 +275,59 @@ class RotationNotifier extends StateNotifier<int> {
void _startRotation() { void _startRotation() {
_timer?.cancel(); _timer?.cancel();
_elapsed = 0; _elapsed = 0;
_ref.read(rotationElapsedProvider.notifier).state = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
final screenState = _ref.read(screenStateProvider); final screenState = _ref.read(screenStateProvider);
if (screenState.state != ScreenState.normal) { if (screenState.state != ScreenState.normal) {
// Don't rotate during special states, reset elapsed // Don't rotate during special states, reset elapsed
_elapsed = 0; _elapsed = 0;
_ref.read(rotationElapsedProvider.notifier).state = 0;
return; return;
} }
_elapsed++;
final settings = _ref.read(settingsProvider); 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; final hasContent = validSlides.isNotEmpty;
_elapsed++;
if (!hasContent) { if (!hasContent) {
final duration = _resolveMainPhaseDuration(settings);
if (_elapsed >= duration) {
_elapsed = 0; _elapsed = 0;
}
_ref.read(rotationElapsedProvider.notifier).state = _elapsed;
if (state != 0) state = 0; // force main screen state if (state != 0) state = 0; // force main screen state
return; return;
} }
final isMainScreen = state % 2 == 0; final isMainScreen = state % 2 == 0;
final duration = isMainScreen final duration = isMainScreen
? settings.mainScreenDurationSec ? _resolveMainPhaseDuration(settings)
: settings.slideDurationSec; : settings.slideDurationSec.clamp(1, 600);
if (_elapsed >= duration) { if (_elapsed >= duration) {
_elapsed = 0; _elapsed = 0;
state = state + 1; 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 @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
@@ -306,11 +340,14 @@ class RotationNotifier extends StateNotifier<int> {
/// Unsplash background is disabled — no point rotating to an empty slide. /// Unsplash background is disabled — no point rotating to an empty slide.
final isMainScreenProvider = Provider<bool>((ref) { final isMainScreenProvider = Provider<bool>((ref) {
final settings = ref.watch(settingsProvider); 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; final hasContent = validSlides.isNotEmpty;
if (!hasContent) return true; // always stay on main screen if (!hasContent) return true; // always stay on main screen
final index = ref.watch(rotationIndexProvider);
// Even = main, Odd = slideshow // Even = main, Odd = slideshow
return index % 2 == 0; 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.4+5 version: 1.0.5+6
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'