Files
jamshalat-masjid-screen/lib/features/home/main_screen.dart

1020 lines
32 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import '../../core/hijri_date.dart';
import '../../core/sacred_tokens.dart';
import '../../core/enums.dart';
import '../../providers.dart';
import '../../data/local/models.dart';
import 'unsplash_background.dart';
import 'dart:io';
/// FORMAT HELPER: Duration → "HH:MM:SS"
String _fmtDuration(Duration d) {
final h = d.inHours.toString().padLeft(2, '0');
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
if (d.inHours > 0) return '$h:$m:$s';
return '$m:$s';
}
/// The primary display — clock, prayer cards, countdown, marquee.
class MainScreen extends ConsumerWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final fs = s * ref.watch(textScaleProvider);
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 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)
Positioned.fill(
child: Image.file(
File(settings.brandedBgImage!),
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.55),
colorBlendMode: BlendMode.darken,
errorBuilder: (_, __, ___) => const UnsplashBackground(),
),
)
else
// ── Underlay 2: API Unsplash Landscape (fallback) ──
const UnsplashBackground(),
// ── Background radial tint overlay ──
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
SacredColors.primary.withValues(alpha: 0.15),
SacredColors.background,
],
),
),
),
),
// ── Vignette ──
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
SacredColors.background.withValues(alpha: 0.8),
Colors.transparent,
SacredColors.background.withValues(alpha: 0.9),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
// ── Main content column ──
Padding(
padding: EdgeInsets.symmetric(horizontal: 64 * s),
child: Column(
children: [
// ── HEADER ──
_buildHeader(
context,
s,
fs,
settings,
dateGregorian,
dateHijri,
inlineClockText:
centerSlide.isPrimary ? null : '$timeStr$secStr',
),
// ── CENTER: Clock + Countdown ──
Expanded(
child: Center(
child: centerSlide.isPrimary
? _buildPrimaryCenter(
s,
fs,
timeStr,
secStr,
dateGregorian,
screenData,
schedule,
settings,
)
: _buildAnnouncementCenter(
s,
fs,
settings,
centerSlide,
centerTextSlides,
),
),
),
// ── FOOTER: Prayer Cards ──
if (schedule != null)
_buildPrayerCardsRow(
s, fs, schedule, screenData, settings, clock),
SizedBox(height: 16 * s),
// ── MARQUEE ──
_buildMarquee(s, fs, settings),
SizedBox(height: 12 * s),
],
),
),
],
),
);
}
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(
children: [
// Left: Mosque name + address
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 * 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,
),
),
),
],
),
],
),
),
),
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
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,
),
),
],
),
),
),
],
),
);
}
_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),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.2), width: 1),
color: SacredColors.primary.withValues(alpha: 0.05),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pulsing dot
_PulsingDot(color: SacredColors.secondary, size: 10 * s),
SizedBox(width: 12 * s),
Text(
'Menuju Adzan ${screenData.nextPrayer?.displayLabel(isFriday: screenData.isFriday) ?? ""}: '
'${_fmtDuration(screenData.timeUntilNext!)}',
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * fs,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 0.5 * s,
),
),
],
),
);
}
Widget _buildSecondaryTimes(
double s, double fs, DailyPrayerSchedule schedule, AppSettings settings) {
final items = <_SecondaryTimeItem>[];
if (settings.showImsak) {
items.add(_SecondaryTimeItem('Imsak', schedule.imsak));
}
if (settings.showTerbit) {
items.add(_SecondaryTimeItem('Terbit', schedule.terbit));
}
items.add(_SecondaryTimeItem('Dhuha', schedule.dhuha));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < items.length; i++) ...[
Column(
children: [
Text(
items[i].label.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 10 * fs,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 3 * s,
),
),
SizedBox(height: 4 * s),
Text(
items[i].time,
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * fs,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
),
),
],
),
if (i < items.length - 1) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 24 * s),
child: Container(
width: 1,
height: 40 * s,
color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
],
],
],
);
}
Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule,
ScreenStateData screenData, AppSettings settings, DateTime clock) {
final prayers = [
_PrayerCardData(PrayerName.subuh, schedule.subuh,
'Iqamah ${_addMinutes(schedule.subuh, settings.iqomahSubuh)}'),
_PrayerCardData(PrayerName.dzuhur, schedule.dzuhur,
'Iqamah ${_addMinutes(schedule.dzuhur, settings.iqomahDzuhur)}'),
_PrayerCardData(PrayerName.ashar, schedule.ashar,
'Iqamah ${_addMinutes(schedule.ashar, settings.iqomahAshar)}'),
_PrayerCardData(PrayerName.maghrib, schedule.maghrib,
'Iqamah ${_addMinutes(schedule.maghrib, settings.iqomahMaghrib)}'),
_PrayerCardData(PrayerName.isya, schedule.isya,
'Iqamah ${_addMinutes(schedule.isya, settings.iqomahIsya)}'),
];
// Optionally insert Terbit
if (settings.showTerbit) {
prayers.insert(
1, _PrayerCardData(PrayerName.terbit, schedule.terbit, '-'));
}
return SizedBox(
height: (140 * s * settings.scaleCardBody).clamp(110 * s, 240 * s),
child: Row(
children: [
for (int i = 0; i < prayers.length; i++) ...[
Expanded(
child: _PrayerCard(
data: prayers[i],
isActive: screenData.nextPrayer == prayers[i].name,
isFriday: screenData.isFriday,
s: s,
fs: fs,
scaleLabel: settings.scaleCardLabel,
scaleBody: settings.scaleCardBody,
),
),
if (i < prayers.length - 1) SizedBox(width: 12 * s),
],
],
),
);
}
Widget _buildMarquee(double s, double fs, AppSettings settings) {
final texts = settings.runningTexts;
if (texts.isEmpty) return const SizedBox.shrink();
// Pad durations list to match texts length
final durations = List<int>.generate(
texts.length,
(i) => (i < settings.runningTextDurations.length)
? settings.runningTextDurations[i]
: 12,
);
return Container(
width: double.infinity,
height: 44 * s,
decoration: BoxDecoration(
color: SacredColors.background.withValues(alpha: 0.9),
border: Border(
top: BorderSide(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.1),
),
),
),
child: ClipRect(
child: _RunningTextWidget(
texts: texts,
durations: durations,
animType: settings.marqueeAnimType,
style: GoogleFonts.manrope(
fontSize: 16 * fs * settings.scaleRunningText,
fontWeight: FontWeight.w500,
color: SacredColors.secondary,
letterSpacing: 0.8 * s,
),
),
),
);
}
String _addMinutes(String time, int minutes) {
final parts = time.split(':');
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final dt = DateTime(2000, 1, 1, h, m).add(Duration(minutes: minutes));
return DateFormat('HH:mm').format(dt);
}
}
// ─── 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;
_SecondaryTimeItem(this.label, this.time);
}
class _PrayerCardData {
final PrayerName name;
final String time;
final String iqomahLabel;
_PrayerCardData(this.name, this.time, this.iqomahLabel);
}
class _PrayerCard extends StatelessWidget {
final _PrayerCardData data;
final bool isActive;
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
const _PrayerCard({
required this.data,
required this.isActive,
required this.isFriday,
required this.s,
required this.fs,
this.scaleLabel = 1.0,
this.scaleBody = 1.0,
});
@override
Widget build(BuildContext context) {
final label = data.name.displayLabel(isFriday: isFriday);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: isActive
? SacredColors.primaryContainer
: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: isActive
? Border.all(color: SacredColors.primary, width: 2 * s)
: null,
boxShadow: isActive
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.1),
blurRadius: 40 * s,
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs * scaleLabel,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.onPrimaryContainer
: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
if (isActive)
Icon(Icons.notifications_active,
color: SacredColors.onPrimaryContainer, size: 16 * s),
],
),
Text(
data.time,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * fs * scaleBody,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.onPrimaryContainer
: SacredColors.onSurface,
),
),
Text(
data.iqomahLabel,
style: GoogleFonts.manrope(
fontSize: 10 * fs * scaleBody,
color: isActive
? SacredColors.onPrimaryContainer.withValues(alpha: 0.8)
: SacredColors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
final double size;
const _PulsingDot({required this.color, required this.size});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
children: [
FadeTransition(
opacity: Tween(begin: 0.75, end: 0.0).animate(_ctrl),
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 2.0).animate(_ctrl),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color,
),
),
),
),
Center(
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color,
),
),
),
],
),
);
}
}
// ─── Running Text Widget (marquee + fade modes) ───
class _RunningTextWidget extends StatefulWidget {
final List<String> texts;
final List<int> durations; // per-item seconds
final String animType; // 'marquee' or 'fade'
final TextStyle style;
const _RunningTextWidget({
required this.texts,
required this.durations,
required this.animType,
required this.style,
});
@override
State<_RunningTextWidget> createState() => _RunningTextWidgetState();
}
class _RunningTextWidgetState extends State<_RunningTextWidget>
with TickerProviderStateMixin {
int _index = 0;
bool _disposed = false;
late AnimationController _fadeCtrl;
late AnimationController _scrollCtrl;
@override
void initState() {
super.initState();
_fadeCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_scrollCtrl = AnimationController(vsync: this);
_startCycle();
}
void _startCycle() async {
try {
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 (_disposed) break;
if (mounted) {
setState(() {
_index = (_index + 1) % widget.texts.length;
});
}
}
} on TickerCanceled {
// Widget disposed while animation was running — exit cleanly
} catch (e) {
if (!_disposed) rethrow;
}
}
@override
void dispose() {
_disposed = true;
_fadeCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final text = widget.texts[_index];
if (widget.animType == 'fade') {
return Center(
child: FadeTransition(
opacity: _fadeCtrl,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline, color: SacredColors.secondary, size: 16),
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
],
),
),
);
}
// Marquee mode
return AnimatedBuilder(
animation: _scrollCtrl,
builder: (context, child) {
final width = MediaQuery.of(context).size.width;
final offset = _scrollCtrl.value * (width + 600);
return Transform.translate(
offset: Offset(width - offset, 0),
child: child,
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline, color: SacredColors.secondary, size: 16),
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),
const SizedBox(width: 80),
],
),
);
}
}