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

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

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(
MaterialPageRoute(
builder: (_) => const AdminScreen(
initialTab: 4,
initialTab: 5,
focusSelectedTabOnOpen: true,
),
),

View File

@@ -38,15 +38,26 @@ class MainScreen extends ConsumerWidget {
final timeStr = DateFormat('HH:mm').format(clock);
final secStr = DateFormat(':ss').format(clock);
final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'id').format(clock);
final dateHijri =
ref.watch(hijriDateProvider).valueOrNull ?? HijriDateFormatter.format(clock);
final dateHijri = ref.watch(hijriDateProvider).valueOrNull ??
HijriDateFormatter.format(clock);
final rotationElapsed = ref.watch(rotationElapsedProvider);
final centerTextSlides = settings.textSlides
.map((text) => text.trim())
.where((text) => text.isNotEmpty)
.toList();
final centerSlide = _resolveCenterSlide(
settings: settings,
elapsedInMainWindowSec: rotationElapsed,
announcements: centerTextSlides,
);
return Container(
color: SacredColors.background,
child: Stack(
children: [
// ── Underlay 1: Branded local image (highest priority if set) ──
if (settings.brandedBgImage != null && settings.brandedBgImage!.isNotEmpty)
if (settings.brandedBgImage != null &&
settings.brandedBgImage!.isNotEmpty)
Positioned.fill(
child: Image.file(
File(settings.brandedBgImage!),
@@ -100,97 +111,38 @@ class MainScreen extends ConsumerWidget {
child: Column(
children: [
// ── HEADER ──
_buildHeader(context, s, fs, settings, dateGregorian, dateHijri),
_buildHeader(
context,
s,
fs,
settings,
dateGregorian,
dateHijri,
inlineClockText:
centerSlide.isPrimary ? null : '$timeStr$secStr',
),
// ── CENTER: Clock + Countdown ──
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Countdown pill
if (screenData.nextPrayer != null &&
screenData.timeUntilNext != null)
_buildCountdownPill(s, fs, screenData),
SizedBox(height: 16 * s),
// Massive Clock
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 180 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -6 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 40 * s,
color:
SacredColors.primary.withValues(alpha: 0.2),
),
],
),
),
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 72 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -1 * s,
),
),
),
],
),
// Decorative line
Container(
width: 240 * s,
height: 2 * s,
margin: EdgeInsets.only(top: 12 * s),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
SacredColors.primary.withValues(alpha: 0.4),
Colors.transparent,
],
),
child: centerSlide.isPrimary
? _buildPrimaryCenter(
s,
fs,
timeStr,
secStr,
dateGregorian,
screenData,
schedule,
settings,
)
: _buildAnnouncementCenter(
s,
fs,
settings,
centerSlide,
centerTextSlides,
),
),
SizedBox(height: 16 * s),
// Date line
Text(
dateGregorian,
style: GoogleFonts.manrope(
fontSize: 24 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 1 * s,
),
),
// Secondary times (Imsak, Terbit, Dhuha)
if (schedule != null)
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: _buildSecondaryTimes(s, fs, schedule, settings),
),
// Removed FRIDAY SPECIAL PANEL since its handled by dedicated JumatScreen
],
),
),
),
@@ -213,108 +165,349 @@ class MainScreen extends ConsumerWidget {
);
}
Widget _buildHeader(
BuildContext context,
double s,
double fs,
AppSettings settings,
String dateGregorian,
String dateHijri,
) {
Widget _buildHeader(BuildContext context, double s, double fs,
AppSettings settings, String dateGregorian, String dateHijri,
{String? inlineClockText}) {
final hScale = settings.scaleTopHeader;
final showInlineClock =
inlineClockText != null && inlineClockText.isNotEmpty;
return Padding(
padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque name + address
Padding(
padding: EdgeInsets.all(8.0 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -0.5 * s,
Expanded(
flex: 5,
child: Padding(
padding: EdgeInsets.all(8.0 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s * hScale,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -0.5 * s,
),
),
),
SizedBox(height: 4 * s),
Row(
children: [
HugeIcon(
icon: HugeIcons.strokeRoundedLocation01,
color: SacredColors.secondary,
size: 16 * s,
),
SizedBox(width: 4 * s),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 14 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.7),
letterSpacing: 0.5 * s,
SizedBox(height: 4 * s),
Row(
children: [
HugeIcon(
icon: HugeIcons.strokeRoundedLocation01,
color: SacredColors.secondary,
size: 16 * s * hScale,
),
),
],
),
],
SizedBox(width: 4 * s),
Expanded(
child: Text(
settings.masjidAddress,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.manrope(
fontSize: 14 * fs * hScale,
fontWeight: FontWeight.w500,
color:
SacredColors.onSurface.withValues(alpha: 0.7),
letterSpacing: 0.5 * s,
),
),
),
],
),
],
),
),
),
// Right: Hijri date + mosque icon
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
dateHijri,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
if (showInlineClock)
Expanded(
flex: 2,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 22 * s,
vertical: 10 * s,
),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow
.withValues(alpha: 0.86),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.32),
),
),
Text(
dateGregorian.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
child: Text(
inlineClockText,
style: GoogleFonts.plusJakartaSans(
fontSize: 30 * s * hScale,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -1 * s,
),
),
),
),
),
// Right: Hijri date + mosque icon
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
dateHijri,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s * hScale,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
Text(
dateGregorian.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs * hScale,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
],
),
SizedBox(width: 16 * s),
Container(
width: 48 * s * hScale,
height: 48 * s * hScale,
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: HugeIcon(
icon: HugeIcons.strokeRoundedHome01,
color: SacredColors.secondary,
size: 28 * s * hScale,
),
),
],
),
SizedBox(width: 16 * s),
Container(
width: 48 * s,
height: 48 * s,
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: HugeIcon(
icon: HugeIcons.strokeRoundedHome01,
color: SacredColors.secondary,
size: 28 * s,
),
),
],
),
),
],
),
);
}
_CenterSlideState _resolveCenterSlide({
required AppSettings settings,
required int elapsedInMainWindowSec,
required List<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) {
return Container(
padding:
EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
@@ -393,8 +586,6 @@ class MainScreen extends ConsumerWidget {
);
}
Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule,
ScreenStateData screenData, AppSettings settings, DateTime clock) {
final prayers = [
@@ -489,6 +680,46 @@ class MainScreen extends ConsumerWidget {
// ─── Supporting widgets ───
class _CenterSlideState {
final bool isPrimary;
final int? announcementIndex;
final int elapsedInSlideSec;
final int slideDurationSec;
final int totalAnnouncements;
const _CenterSlideState._({
required this.isPrimary,
this.announcementIndex,
required this.elapsedInSlideSec,
required this.slideDurationSec,
required this.totalAnnouncements,
});
factory _CenterSlideState.primary({required int heroDuration}) {
return _CenterSlideState._(
isPrimary: true,
elapsedInSlideSec: 0,
slideDurationSec: heroDuration,
totalAnnouncements: 0,
);
}
factory _CenterSlideState.announcement({
required int announcementIndex,
required int elapsedInSlideSec,
required int slideDurationSec,
required int totalAnnouncements,
}) {
return _CenterSlideState._(
isPrimary: false,
announcementIndex: announcementIndex,
elapsedInSlideSec: elapsedInSlideSec,
slideDurationSec: slideDurationSec,
totalAnnouncements: totalAnnouncements,
);
}
}
class _SecondaryTimeItem {
final String label;
final String time;
@@ -508,8 +739,8 @@ class _PrayerCard extends StatelessWidget {
final bool isFriday;
final double s;
final double fs;
final double scaleLabel; // controls prayer name label size
final double scaleBody; // controls time + iqomah text size
final double scaleLabel; // controls prayer name label size
final double scaleBody; // controls time + iqomah text size
const _PrayerCard({
required this.data,
@@ -590,7 +821,6 @@ class _PrayerCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
],
),
);
}
@@ -664,7 +894,7 @@ class _PulsingDotState extends State<_PulsingDot>
class _RunningTextWidget extends StatefulWidget {
final List<String> texts;
final List<int> durations; // per-item seconds
final String animType; // 'marquee' or 'fade'
final String animType; // 'marquee' or 'fade'
final TextStyle style;
const _RunningTextWidget({
@@ -698,34 +928,34 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
void _startCycle() async {
try {
while (!_disposed) {
if (widget.texts.isEmpty) {
await Future.delayed(const Duration(seconds: 1));
continue;
}
final dur = widget.durations[_index];
while (!_disposed) {
if (widget.texts.isEmpty) {
await Future.delayed(const Duration(seconds: 1));
continue;
}
final dur = widget.durations[_index];
if (widget.animType == 'fade') {
if (_disposed) break;
await _fadeCtrl.forward().orCancel;
if (_disposed) break;
await Future.delayed(Duration(seconds: dur));
if (_disposed) break;
await _fadeCtrl.reverse().orCancel;
} else {
if (_disposed) break;
_scrollCtrl.duration = Duration(seconds: dur);
_scrollCtrl.reset();
await _scrollCtrl.forward().orCancel;
}
if (widget.animType == 'fade') {
if (_disposed) break;
await _fadeCtrl.forward().orCancel;
if (_disposed) break;
await Future.delayed(Duration(seconds: dur));
if (_disposed) break;
await _fadeCtrl.reverse().orCancel;
} else {
if (_disposed) break;
_scrollCtrl.duration = Duration(seconds: dur);
_scrollCtrl.reset();
await _scrollCtrl.forward().orCancel;
}
if (_disposed) break;
if (mounted) {
setState(() {
_index = (_index + 1) % widget.texts.length;
});
if (_disposed) break;
if (mounted) {
setState(() {
_index = (_index + 1) % widget.texts.length;
});
}
}
}
} on TickerCanceled {
// Widget disposed while animation was running — exit cleanly
} catch (e) {
@@ -741,7 +971,6 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
super.dispose();
}
@override
Widget build(BuildContext context) {
final text = widget.texts[_index];
@@ -753,8 +982,7 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline,
color: SacredColors.secondary, size: 16),
Icon(Icons.info_outline, color: SacredColors.secondary, size: 16),
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
],
@@ -781,7 +1009,8 @@ class _RunningTextWidgetState extends State<_RunningTextWidget>
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
const SizedBox(width: 80),
Icon(Icons.circle, color: SacredColors.secondary.withValues(alpha: 0.4), size: 6),
Icon(Icons.circle,
color: SacredColors.secondary.withValues(alpha: 0.4), size: 6),
const SizedBox(width: 80),
],
),

View File

@@ -59,10 +59,13 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
final textScaleProvider = Provider<double>((ref) {
final index = ref.watch(settingsProvider.select((s) => s.textScaleIndex));
switch (index) {
case 0: return 0.85; // Small
case 2: return 1.15; // Large
case 0:
return 0.85; // Small
case 2:
return 1.15; // Large
case 1:
default: return 1.0; // Medium
default:
return 1.0; // Medium
}
});
@@ -103,11 +106,11 @@ final hijriDateProvider = FutureProvider<String>((ref) async {
/// Computed state that tells the UI which screen to display.
class ScreenStateData {
final ScreenState state;
final PrayerName? activePrayer; // Current or next prayer
final PrayerName? activePrayer; // Current or next prayer
final PrayerName? nextPrayer;
final Duration? timeUntilNext; // Countdown to next prayer time
final Duration? iqomahRemaining; // Countdown during iqomah state
final Duration? blankRemaining; // Countdown during shalat/blank state
final Duration? timeUntilNext; // Countdown to next prayer time
final Duration? iqomahRemaining; // Countdown during iqomah state
final Duration? blankRemaining; // Countdown during shalat/blank state
final bool isFriday;
final DateTime now;
@@ -151,12 +154,18 @@ final screenStateProvider = Provider<ScreenStateData>((ref) {
int iqomahMinutes(PrayerName p) {
switch (p) {
case PrayerName.subuh: return settings.iqomahSubuh;
case PrayerName.dzuhur: return settings.iqomahDzuhur;
case PrayerName.ashar: return settings.iqomahAshar;
case PrayerName.maghrib: return settings.iqomahMaghrib;
case PrayerName.isya: return settings.iqomahIsya;
default: return 10;
case PrayerName.subuh:
return settings.iqomahSubuh;
case PrayerName.dzuhur:
return settings.iqomahDzuhur;
case PrayerName.ashar:
return settings.iqomahAshar;
case PrayerName.maghrib:
return settings.iqomahMaghrib;
case PrayerName.isya:
return settings.iqomahIsya;
default:
return 10;
}
}
@@ -172,8 +181,7 @@ final screenStateProvider = Provider<ScreenStateData>((ref) {
adzanTime.subtract(Duration(minutes: settings.preAdzanLead));
final iqomahDuration = Duration(minutes: iqomahMinutes(prayer.key));
final iqomahEnd = adzanTime.add(iqomahDuration);
final blankEnd =
iqomahEnd.add(Duration(minutes: blankMinutes()));
final blankEnd = iqomahEnd.add(Duration(minutes: blankMinutes()));
// STATE: SHALAT (Black Screen)
if (clock.isAfter(iqomahEnd) && clock.isBefore(blankEnd)) {
@@ -246,6 +254,9 @@ final screenStateProvider = Provider<ScreenStateData>((ref) {
// 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.
final rotationIndexProvider =
StateNotifierProvider<RotationNotifier, int>((ref) {
@@ -264,36 +275,59 @@ class RotationNotifier extends StateNotifier<int> {
void _startRotation() {
_timer?.cancel();
_elapsed = 0;
_ref.read(rotationElapsedProvider.notifier).state = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
final screenState = _ref.read(screenStateProvider);
if (screenState.state != ScreenState.normal) {
// Don't rotate during special states, reset elapsed
_elapsed = 0;
_ref.read(rotationElapsedProvider.notifier).state = 0;
return;
}
_elapsed++;
final settings = _ref.read(settingsProvider);
final validSlides = settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final validSlides =
settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty;
_elapsed++;
if (!hasContent) {
_elapsed = 0;
final duration = _resolveMainPhaseDuration(settings);
if (_elapsed >= duration) {
_elapsed = 0;
}
_ref.read(rotationElapsedProvider.notifier).state = _elapsed;
if (state != 0) state = 0; // force main screen state
return;
}
final isMainScreen = state % 2 == 0;
final duration = isMainScreen
? settings.mainScreenDurationSec
: settings.slideDurationSec;
? _resolveMainPhaseDuration(settings)
: settings.slideDurationSec.clamp(1, 600);
if (_elapsed >= duration) {
_elapsed = 0;
state = state + 1;
}
_ref.read(rotationElapsedProvider.notifier).state = _elapsed;
});
}
int _resolveMainPhaseDuration(AppSettings settings) {
final centerSlides = settings.textSlides
.map((text) => text.trim())
.where((text) => text.isNotEmpty)
.toList();
if (centerSlides.isEmpty) {
return settings.mainScreenDurationSec.clamp(1, 600);
}
final heroDuration = settings.mainCenterSlideDurationSec.clamp(1, 600);
final perAnnouncement = settings.announcementSlideDurationSec.clamp(1, 600);
return heroDuration + (perAnnouncement * centerSlides.length);
}
@override
void dispose() {
_timer?.cancel();
@@ -306,11 +340,14 @@ class RotationNotifier extends StateNotifier<int> {
/// Unsplash background is disabled — no point rotating to an empty slide.
final isMainScreenProvider = Provider<bool>((ref) {
final settings = ref.watch(settingsProvider);
final validSlides = settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
// Keep rotation notifier alive even when slideshow media is empty,
// because main-screen text slides depend on rotation elapsed time.
final index = ref.watch(rotationIndexProvider);
final validSlides =
settings.slideshowImages.where((i) => i.trim().isNotEmpty).toList();
final hasContent = validSlides.isNotEmpty;
if (!hasContent) return true; // always stay on main screen
final index = ref.watch(rotationIndexProvider);
// Even = main, Odd = slideshow
return index % 2 == 0;
});