Files
jamshalat-masjid-screen/lib/features/home/main_screen.dart
2026-03-30 22:14:51 +07:00

790 lines
25 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);
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,
),
)
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),
// ── 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,
],
),
),
),
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
],
),
),
),
// ── 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,
) {
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,
),
),
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,
),
),
],
),
],
),
),
// 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,
),
),
Text(
dateGregorian.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
],
),
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,
),
),
],
),
],
),
);
}
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 _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),
],
),
);
}
}