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

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),
],
),