Initial project import and stabilization baseline

This commit is contained in:
dwindown
2026-03-30 21:28:44 +07:00
commit ad33b01231
186 changed files with 20445 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Full-screen Adzan alert with pulsing icon and glowing text.
class AdzanAlertScreen extends ConsumerWidget {
const AdzanAlertScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
final timeStr =
'${clock.hour.toString().padLeft(2, '0')}:${clock.minute.toString().padLeft(2, '0')}';
final secStr = clock.second.toString().padLeft(2, '0');
final fs = s * ref.watch(textScaleProvider);
return Container(
color: SacredColors.background,
child: Stack(
children: [
// Background gradient
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
SacredColors.background,
SacredColors.background,
],
),
),
),
),
// Ghost mosque icon
Positioned(
left: 40 * s,
top: 0,
bottom: 0,
child: Center(
child: Opacity(
opacity: 0.03,
child: Icon(Icons.mosque, size: 500 * s,
color: SacredColors.onSurface),
),
),
),
// ── Header ──
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 64 * s, vertical: 24 * s),
color: SacredColors.background,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 14 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.secondary,
),
),
],
),
],
),
),
),
// ── Central Alert Content ──
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Pulsing bell icon with glow
_PulsingIcon(scale: s),
SizedBox(height: 40 * s),
// "WAKTU ADZAN [PRAYER]"
Text(
'WAKTU ADZAN $prayerLabel',
style: GoogleFonts.plusJakartaSans(
fontSize: 80 * s,
fontWeight: FontWeight.w800,
color: SacredColors.secondary,
letterSpacing: -2 * s,
shadows: [
Shadow(
blurRadius: 40 * s,
color: SacredColors.secondary.withValues(alpha: 0.4),
),
],
),
),
SizedBox(height: 32 * s),
// Clock in pill
Container(
padding: EdgeInsets.symmetric(
horizontal: 48 * s, vertical: 20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.15),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 120 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -3 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 30 * s,
color:
SacredColors.primary.withValues(alpha: 0.3),
),
],
),
),
SizedBox(width: 12 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface
.withValues(alpha: 0.5),
),
),
Text(
'WIB',
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
color: SacredColors.primary,
letterSpacing: 4 * s,
),
),
],
),
],
),
),
SizedBox(height: 40 * s),
// Sub-message
Text(
'Telah masuk waktu shalat $prayerLabel.\nSegera persiapkan diri menuju masjid.',
textAlign: TextAlign.center,
style: GoogleFonts.manrope(
fontSize: 24 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.8),
height: 1.5,
),
),
],
),
),
// ── Footer: Prayer times strip ──
if (schedule != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooterStrip(s, fs, schedule, screenData),
),
],
),
);
}
Widget _buildFooterStrip(
double s, double fs, dynamic schedule, dynamic screenData) {
final prayers = {
'Subuh': schedule.subuh,
'Dzuhur': schedule.dzuhur,
'Ashar': schedule.ashar,
'Maghrib': schedule.maghrib,
'Isya': schedule.isya,
};
return Container(
padding: EdgeInsets.symmetric(horizontal: 64 * s, vertical: 28 * s),
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: prayers.entries.map((e) {
final isActive = screenData.activePrayer?.id == e.key.toLowerCase();
return Column(
children: [
Text(
e.key.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.primary
: SacredColors.onSurface.withValues(alpha: 0.4),
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
e.value,
style: GoogleFonts.plusJakartaSans(
fontSize: isActive ? 32 * fs : 28 * fs,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
color: isActive
? SacredColors.primary
: SacredColors.onSurface,
),
),
],
);
}).toList(),
),
);
}
}
class _PulsingIcon extends StatefulWidget {
final double scale;
const _PulsingIcon({required this.scale});
@override
State<_PulsingIcon> createState() => _PulsingIconState();
}
class _PulsingIconState extends State<_PulsingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
Container(
width: 200 * s,
height: 200 * s,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SacredColors.secondary
.withValues(alpha: 0.1 * _ctrl.value),
boxShadow: [
BoxShadow(
blurRadius: 60 * s * _ctrl.value,
color: SacredColors.secondary.withValues(alpha: 0.1),
),
],
),
),
Icon(
Icons.notifications_active,
size: 120 * s,
color: SacredColors.secondary,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Minimal black screen during prayer — absolute zero distraction.
class BlackScreen extends ConsumerWidget {
const BlackScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
return Container(
color: SacredColors.blackScreen,
child: Stack(
children: [
// Extremely subtle radial tonal shift
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.2,
colors: [
SacredColors.primaryContainer.withValues(alpha: 0.03),
SacredColors.blackScreen,
],
),
),
),
),
// Center: prayer name + status (extremely dim)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.10,
child: Text(
prayerLabel.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 96 * s,
fontWeight: FontWeight.w300,
color: SacredColors.onSurface,
letterSpacing: 20 * s,
),
),
),
SizedBox(height: 16 * s),
Opacity(
opacity: 0.08,
child: Text(
'SHALAT SEDANG BERLANGSUNG',
style: GoogleFonts.plusJakartaSans(
fontSize: 14 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
letterSpacing: 6 * s,
),
),
),
],
),
),
// Bottom advisory — barely visible
Positioned(
bottom: 64 * s,
left: 0,
right: 0,
child: Center(
child: Opacity(
opacity: 0.06,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.phonelink_ring,
size: 14 * s, color: SacredColors.onSurface),
SizedBox(width: 8 * s),
Text(
'MOHON NONAKTIFKAN ALAT KOMUNIKASI',
style: GoogleFonts.manrope(
fontSize: 10 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
letterSpacing: 3 * s,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/sound_service.dart';
import '../../core/enums.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import 'main_screen.dart';
import 'adzan_screen.dart';
import 'iqomah_screen.dart';
import 'black_screen.dart';
import 'slideshow_screen.dart';
import 'jumat_screen.dart';
import 'khutbah_screen.dart';
/// The root view that orchestrates all screen states via AnimatedSwitcher.
class HomeView extends ConsumerStatefulWidget {
const HomeView({super.key});
@override
ConsumerState<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends ConsumerState<HomeView> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAutoSync();
});
}
Future<void> _checkAutoSync() async {
final schedule = ref.read(todayScheduleProvider);
if (schedule == null) {
debugPrint('[AutoSync] No schedule found for today! Starting auto-sync...');
final success = await SyncService.instance.syncMonthlyData();
if (success && mounted) {
debugPrint('[AutoSync] Success! Invalidating todayScheduleProvider.');
ref.invalidate(todayScheduleProvider);
} else {
debugPrint('[AutoSync] Failed or data remained empty.');
}
}
}
@override
Widget build(BuildContext context) {
// Audio trigger listener
ref.listen<ScreenStateData>(screenStateProvider, (previous, next) {
if (previous == null) return;
// TRIGGER 1: Adzan Beep (Fires precisely when transitioning to Adzan)
if (previous.state != ScreenState.adzan && next.state == ScreenState.adzan) {
SoundService.instance.playAdzanBeep();
}
// TRIGGER 2: 3-Second Iqomah Countdown
if (next.state == ScreenState.menujuIqomah && next.iqomahRemaining != null) {
// Play precisely on the tick where it is 3 seconds.
if (previous.iqomahRemaining?.inSeconds != 3 && next.iqomahRemaining!.inSeconds == 3) {
SoundService.instance.playIqomahCountdown();
}
}
});
final screenData = ref.watch(screenStateProvider);
final isMainScreen = ref.watch(isMainScreenProvider);
// Determine which screen to display
Widget screen;
switch (screenData.state) {
case ScreenState.normal:
case ScreenState.menujuAdzan:
if (screenData.isFriday && screenData.nextPrayer?.id == 'dzuhur') {
screen = const JumatScreen(key: ValueKey('jumat'));
} else {
screen = isMainScreen
? const MainScreen(key: ValueKey('main'))
: const SlideshowScreen(key: ValueKey('slideshow'));
}
break;
case ScreenState.kembaliNormal:
screen = const MainScreen(key: ValueKey('main'));
break;
case ScreenState.adzan:
screen = const AdzanAlertScreen(key: ValueKey('adzan'));
break;
case ScreenState.menujuIqomah:
if (screenData.isFriday && screenData.activePrayer?.id == 'dzuhur') {
screen = const KhutbahScreen(key: ValueKey('khutbah'));
} else {
screen = const IqomahScreen(key: ValueKey('iqomah'));
}
break;
case ScreenState.shalat:
screen = const BlackScreen(key: ValueKey('black'));
break;
}
final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero;
return Scaffold(
backgroundColor: SacredColors.background,
body: Stack(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 800),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: screen,
),
if (isSimulating)
Positioned(
right: 64,
bottom: 64,
child: ElevatedButton.icon(
onPressed: () {
ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero;
},
icon: const Icon(Icons.cancel, color: Colors.white),
label: const Text(
'BATALKAN SIMULASI',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 2,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade800,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 10,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// FORMAT HELPER: Duration → "MM:SS"
String _fmtCountdown(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
/// Iqomah countdown screen — and Friday Khutbah info override.
class IqomahScreen extends ConsumerWidget {
const IqomahScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
final countdown = screenData.iqomahRemaining ?? Duration.zero;
final isFridayDzuhur =
screenData.isFriday && screenData.activePrayer?.id == 'dzuhur';
return Container(
color: SacredColors.background,
child: Stack(
children: [
// Subtle radial glow
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.7,
colors: [
SacredColors.primary.withValues(alpha: 0.08),
SacredColors.background,
],
),
),
),
),
// ── Content ──
Padding(
padding: EdgeInsets.all(64 * s),
child: Column(
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: -1 * s,
),
),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 12 * s,
color: SacredColors.onSurface.withValues(alpha: 0.6),
letterSpacing: 4 * s,
),
),
],
),
],
),
// ── Center: Countdown ──
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Prayer name pill
Text(
'SHALAT SAAT INI',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.5),
letterSpacing: 8 * s,
),
),
SizedBox(height: 12 * s),
Container(
padding: EdgeInsets.symmetric(
horizontal: 48 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.1),
),
),
child: Text(
prayerLabel.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 56 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: 2 * s,
),
),
),
SizedBox(height: 32 * s),
// Title
Text(
isFridayDzuhur
? 'PERSIAPAN KHUTBAH'
: 'MENUJU IQOMAH',
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 4 * s,
),
),
SizedBox(height: 16 * s),
// Giant timer
Text(
_fmtCountdown(countdown),
style: GoogleFonts.plusJakartaSans(
fontSize: 280 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -8 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 40 * s,
color:
SacredColors.primary.withValues(alpha: 0.3),
),
],
),
),
// Pulsing status pill
_StatusPill(
label: 'SIAPKAN DIRI ANDA',
scale: s,
),
// Friday officers info
if (isFridayDzuhur) ...[
SizedBox(height: 32 * s),
_FridayOfficers(settings: settings, scale: s),
],
],
),
),
),
// Hadith reminder
Container(
padding: EdgeInsets.all(32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border(
left: BorderSide(
color: SacredColors.secondary, width: 4 * s),
),
),
child: Text(
'"Luruskan dan Rapatkan Shaf, Sesungguhnya lurusnya shaf termasuk kesempurnaan shalat."',
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
fontStyle: FontStyle.italic,
height: 1.5,
),
),
),
],
),
),
],
),
);
}
}
class _StatusPill extends StatefulWidget {
final String label;
final double scale;
const _StatusPill({required this.label, required this.scale});
@override
State<_StatusPill> createState() => _StatusPillState();
}
class _StatusPillState extends State<_StatusPill>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(seconds: 2))
..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
return FadeTransition(
opacity: Tween(begin: 0.5, end: 1.0).animate(_ctrl),
child: Container(
margin: EdgeInsets.only(top: 16 * s),
padding:
EdgeInsets.symmetric(horizontal: 32 * s, vertical: 12 * s),
decoration: BoxDecoration(
color: SacredColors.secondary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.secondary.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_active,
color: SacredColors.secondary, size: 20 * s),
SizedBox(width: 8 * s),
Text(
widget.label,
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 3 * s,
),
),
],
),
),
);
}
}
class _FridayOfficers extends StatelessWidget {
final dynamic settings;
final double scale;
const _FridayOfficers({required this.settings, required this.scale});
@override
Widget build(BuildContext context) {
final s = scale;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_OfficerCard(
icon: Icons.person_pin,
label: 'KHATIB',
name: settings.khatibName,
color: SacredColors.primary,
scale: s,
),
SizedBox(width: 24 * s),
_OfficerCard(
icon: Icons.timer,
label: 'IMAM',
name: settings.imamName,
color: SacredColors.secondary,
scale: s,
),
],
);
}
}
class _OfficerCard extends StatelessWidget {
final IconData icon;
final String label;
final String name;
final Color color;
final double scale;
const _OfficerCard({
required this.icon,
required this.label,
required this.name,
required this.color,
required this.scale,
});
@override
Widget build(BuildContext context) {
final s = scale;
return Container(
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHigh.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(SacredRadii.lg),
),
child: Icon(icon, color: color, size: 24 * s),
),
SizedBox(width: 16 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 10 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface.withValues(alpha: 0.4),
letterSpacing: 3 * s,
),
),
Text(
name,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,530 @@
import 'dart:io';
import 'dart:ui';
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 '../../providers.dart';
import '../../data/local/models.dart';
import '../admin/admin_screen.dart';
import 'unsplash_background.dart';
/// A highly polished, dedicated screen displayed specifically on Fridays
/// when transitioning towards Dzuhur (Jumat) prayer.
class JumatScreen extends ConsumerWidget {
const JumatScreen({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', 'en').format(clock);
final dateHijri = HijriDateFormatter.format(clock);
final durToKhutbah = screenData.timeUntilNext ?? const Duration(minutes: 0);
final minToKhutbah = durToKhutbah.inMinutes;
return Container(
color: SacredColors.background,
child: Stack(
children: [
// ── Underlay: Branded local image or Unsplash ──
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
const UnsplashBackground(),
// ── Background darkness gradient ──
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
SacredColors.background.withValues(alpha: 0.8),
Colors.transparent,
SacredColors.background.withValues(alpha: 0.9),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
// ── Header Shell ──
Positioned(
top: 48 * s,
left: 64 * s,
right: 64 * s,
child: _buildHeader(context, settings, s),
),
// ── Main Content Canvas ──
Positioned.fill(
top: 140 * s,
bottom: 240 * s,
left: 64 * s,
right: 64 * s,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// ── Left Column: Clock & Primary Focus ──
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pill
Container(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration(
color: SacredColors.secondary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(color: SacredColors.secondary.withValues(alpha: 0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(color: SacredColors.secondary, size: 12 * s),
SizedBox(width: 12 * s),
Text(
'PERSIAPAN JUMAT',
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 3 * s,
),
),
],
),
),
SizedBox(height: 16 * s),
// Massive Clock
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 192 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: -5 * s,
height: 1.0,
),
),
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary.withValues(alpha: 0.7),
),
),
),
],
),
SizedBox(height: 16 * s),
// Dates
Text(
dateGregorian,
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 4 * s),
Text(
dateHijri,
style: GoogleFonts.manrope(
fontSize: 24 * s,
fontWeight: FontWeight.w500,
color: SacredColors.secondary.withValues(alpha: 0.9),
),
),
],
),
),
// ── Right Column: Khutbah Info Card ──
Expanded(
flex: 1,
child: Container(
padding: EdgeInsets.all(40 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHigh.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'NEXT EVENT',
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
letterSpacing: 3 * s,
),
),
HugeIcon(icon: HugeIcons.strokeRoundedSparkles, color: SacredColors.secondary, size: 24 * s),
],
),
SizedBox(height: 16 * s),
Text(
'MENUJU KHUTBAH',
style: GoogleFonts.plusJakartaSans(
fontSize: 42 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
height: 1.1,
),
),
SizedBox(height: 32 * s),
// Khatib Info
_buildInfoTile(
s,
icon: Icons.person_pin,
color: SacredColors.primary,
label: 'KHATIB HARI INI',
value: settings.khatibName.isEmpty ? 'Belum Diatur' : settings.khatibName
),
SizedBox(height: 24 * s),
// Countdown Info
_buildInfoTile(
s,
icon: Icons.timer,
color: SacredColors.secondary,
label: 'KHUTBAH DIMULAI DALAM',
value: minToKhutbah > 0 ? '~ $minToKhutbah Menit' : 'Sebentar Lagi'
),
],
),
),
),
),
),
],
),
),
// ── Bottom Navigation Shell (Prayer Times) ──
if (schedule != null)
Positioned(
left: 64 * s,
right: 64 * s,
bottom: 64 * s,
child: _buildPrayerTimesRow(s, schedule),
),
// ── Footer Marquee Shell ──
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildMarquee(s, fs, settings),
),
],
),
);
}
Widget _buildHeader(BuildContext context, AppSettings settings, double s) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mosque Name
GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdminScreen())),
child: Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
),
// Mosque Address
Row(
children: [
HugeIcon(icon: HugeIcons.strokeRoundedMosque01, color: SacredColors.primary, size: 24 * s),
SizedBox(width: 8 * s),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
),
),
],
),
],
);
}
Widget _buildInfoTile(double s, {required IconData icon, required Color color, required String label, required String value}) {
return Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(SacredRadii.md),
),
child: Icon(icon, color: color, size: 24 * s),
),
SizedBox(width: 16 * s),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 12 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 22 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
),
],
),
);
}
Widget _buildPrayerTimesRow(double s, DailyPrayerSchedule schedule) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
boxShadow: [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.1),
blurRadius: 40 * s,
spreadRadius: 0,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTimeItem(s, 'Fajr', schedule.subuh, Icons.brightness_3, false),
_buildTimeItem(s, 'Terbit', schedule.terbit, Icons.wb_twilight, false),
_buildTimeItem(s, 'JUMAT', schedule.dzuhur, Icons.wb_sunny, true),
_buildTimeItem(s, 'Asr', schedule.ashar, Icons.sunny_snowing, false),
_buildTimeItem(s, 'Maghrib', schedule.maghrib, Icons.wb_cloudy, false),
_buildTimeItem(s, 'Isha', schedule.isya, Icons.bedtime, false),
],
),
);
}
Widget _buildTimeItem(double s, String name, String time, IconData icon, bool isJumat) {
if (isJumat) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 12 * s),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.15),
border: Border.all(color: SacredColors.primary.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Column(
children: [
Icon(icon, color: SacredColors.primary, size: 28 * s),
SizedBox(height: 8 * s),
Text(name, style: GoogleFonts.manrope(fontSize: 18 * s, fontWeight: FontWeight.w800, color: SacredColors.primary)),
SizedBox(height: 4 * s),
Text(time, style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)),
],
),
);
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
child: Column(
children: [
Icon(icon, color: SacredColors.onSurface.withValues(alpha: 0.6), size: 24 * s),
SizedBox(height: 8 * s),
Text(name, style: GoogleFonts.manrope(fontSize: 16 * s, fontWeight: FontWeight.w500, color: SacredColors.onSurface.withValues(alpha: 0.6))),
SizedBox(height: 4 * s),
Text(time, style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
],
),
);
}
Widget _buildMarquee(double s, double fs, AppSettings settings) {
// Quick custom simplified marquee or fallback to settings.runningTexts
final texts = settings.runningTexts.isEmpty
? ["JUMAT MUBARAK: Luruskan dan rapatkan shaf. Harap non-aktifkan alat komunikasi."]
: settings.runningTexts;
return Container(
width: double.infinity,
height: 44 * s,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
),
child: ClipRect(
child: _JumatMarquee(
texts: texts,
s: s,
),
),
);
}
}
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))),
],
),
);
}
}
class _JumatMarquee extends StatefulWidget {
final List<String> texts;
final double s;
const _JumatMarquee({required this.texts, required this.s});
@override
State<_JumatMarquee> createState() => _JumatMarqueeState();
}
class _JumatMarqueeState extends State<_JumatMarquee> with TickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 30))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final joined = widget.texts.join("");
final style = GoogleFonts.manrope(
fontSize: 16 * widget.s,
fontWeight: FontWeight.w600,
color: SacredColors.secondary,
letterSpacing: 2 * widget.s,
);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
AnimatedBuilder(
animation: _ctrl,
builder: (ctx, child) {
// Ensure endless scroll mathematically
return Positioned(
left: -(_ctrl.value * constraints.maxWidth),
top: 0, bottom: 0,
child: Row(
children: [
Container(alignment: Alignment.centerLeft, width: constraints.maxWidth, child: Text(joined, style: style, maxLines: 1)),
Container(alignment: Alignment.centerLeft, width: constraints.maxWidth, child: Text(joined, style: style, maxLines: 1)),
],
),
);
},
),
],
);
}
);
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Screen displayed uniquely during the Friday Khutbah.
/// It acts like a black (shalat) screen to avoid distraction,
/// but features the active Khatib and Muadzin information and a pulsing indicator.
/// NO COUNTDOWNS are displayed.
class KhutbahScreen extends ConsumerWidget {
const KhutbahScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
return Container(
color: Colors.black,
child: Stack(
children: [
// Absolute center elements
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Animated Pulsing Status Pill
Container(
padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.background,
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingMic(size: 24 * s, color: SacredColors.secondary),
SizedBox(width: 16 * s),
Text(
'SEDANG KHUTBAH JUMAT',
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
fontWeight: FontWeight.w800,
color: SacredColors.secondary,
letterSpacing: 4 * s,
),
),
],
),
),
SizedBox(height: 64 * s),
// Officer Info Cards
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildKhutbahOfficerCard(
s: s,
icon: Icons.person_pin,
label: 'KHATIB',
name: settings.khatibName.isEmpty ? 'Khatib Hari Ini' : settings.khatibName,
color: SacredColors.primary,
),
SizedBox(width: 48 * s),
_buildKhutbahOfficerCard(
s: s,
icon: Icons.record_voice_over,
label: 'MUADZIN / IMAM',
name: settings.imamName.isEmpty ? 'Muadzin Hari Ini' : settings.imamName,
color: SacredColors.secondary,
),
],
),
SizedBox(height: 80 * s),
Text(
'"Harap menjaga ketenangan dan menyimak Khutbah."',
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.5),
fontStyle: FontStyle.italic,
),
)
],
),
),
// Simple corner logo/name so it doesn't feel entirely dead
Positioned(
top: 48 * s,
left: 48 * s,
child: Row(
children: [
Icon(Icons.mosque, color: SacredColors.onSurfaceVariant.withValues(alpha: 0.3), size: 24 * s),
SizedBox(width: 12 * s),
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.3),
letterSpacing: 2 * s,
),
),
],
),
)
],
),
);
}
Widget _buildKhutbahOfficerCard({
required double s,
required IconData icon,
required String label,
required String name,
required Color color,
}) {
return Container(
width: 400 * s,
padding: EdgeInsets.all(32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: color.withValues(alpha: 0.1)),
),
child: Column(
children: [
Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 40 * s),
),
SizedBox(height: 24 * s),
Text(
label,
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 4 * s,
),
),
SizedBox(height: 8 * s),
Text(
name,
textAlign: TextAlign.center,
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
),
),
],
),
);
}
}
class _PulsingMic extends StatefulWidget {
final double size;
final Color color;
const _PulsingMic({required this.size, required this.color});
@override
State<_PulsingMic> createState() => _PulsingMicState();
}
class _PulsingMicState extends State<_PulsingMic> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween(begin: 0.3, end: 1.0).animate(_ctrl),
child: Icon(Icons.mic, size: widget.size, color: widget.color),
);
}
}

View File

@@ -0,0 +1,791 @@
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 '../admin/admin_screen.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);
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),
// ── 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) {
final dateHijri = HijriDateFormatter.format(DateTime.now());
return Padding(
padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque name + address (TAPPABLE FOR ADMIN PANEL)
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AdminScreen()),
);
},
borderRadius: BorderRadius.circular(8 * s),
child: 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),
],
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
class SlideshowScreen extends ConsumerStatefulWidget {
const SlideshowScreen({super.key});
@override
ConsumerState<SlideshowScreen> createState() => _SlideshowScreenState();
}
class _SlideshowScreenState extends ConsumerState<SlideshowScreen> {
static int _globalImageIndex = 0;
int _localImageIndex = 0;
@override
void initState() {
super.initState();
final settings = ref.read(settingsProvider);
if (settings.slideshowImages.isNotEmpty) {
_localImageIndex = _globalImageIndex;
_globalImageIndex = (_globalImageIndex + 1) % settings.slideshowImages.length;
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final screenData = ref.watch(screenStateProvider);
final settings = ref.watch(settingsProvider);
Widget renderImage() {
if (settings.slideshowImages.isEmpty) {
return _buildErrorImage(s);
}
final imgPath = settings.slideshowImages[_localImageIndex % settings.slideshowImages.length];
return Image.file(
File(imgPath),
fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => _buildErrorImage(s),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Poster Image
renderImage(),
// 2. Subtle Dark Gradient Overlay at bottom so the "glass bar" pops out cleanly
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 300 * s,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.8),
Colors.transparent,
],
),
),
),
),
// 3. Glassmorphic Bottom Bar (Always showing Clock and Next Prayer)
Positioned(
left: 64 * s,
right: 64 * s,
bottom: 64 * s,
child: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque Name
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 1 * s,
),
),
// Center: Clock
_buildMiniClock(s, screenData),
// Right: Next Prayer
if (screenData.nextPrayer != null && screenData.timeUntilNext != null)
_buildNextPrayer(s, screenData),
],
),
),
),
),
),
],
),
);
}
Widget _buildMiniClock(double s, ScreenStateData data) {
final timeStr = "${data.now.hour.toString().padLeft(2, '0')}:${data.now.minute.toString().padLeft(2, '0')}";
final secStr = data.now.second.toString().padLeft(2, '0');
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 64 * s,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1.0,
),
),
SizedBox(width: 8 * s),
Padding(
padding: EdgeInsets.only(bottom: 8 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
),
],
);
}
Widget _buildNextPrayer(double s, ScreenStateData data) {
final dur = data.timeUntilNext!;
final h = dur.inHours;
final m = (dur.inMinutes % 60);
final countDownStr = h > 0 ? "-$h jam $m mnt" : "-$m mnt";
final prayerTitle = data.nextPrayer!.id.toUpperCase();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MENJELANG $prayerTitle',
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.9),
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
countDownStr,
style: GoogleFonts.manrope(
fontSize: 28 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
],
);
}
Widget _buildErrorImage(double s) {
return Container(
color: SacredColors.surfaceContainerLow,
child: Center(
child: Icon(Icons.broken_image, size: 64 * s, color: SacredColors.onSurfaceVariant),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/unsplash_service.dart';
import '../../providers.dart';
/// Renders a securely rotating background using Unsplash API.
class UnsplashBackground extends ConsumerStatefulWidget {
const UnsplashBackground({super.key});
@override
ConsumerState<UnsplashBackground> createState() => _UnsplashBackgroundState();
}
class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
List<String> _urls = [];
int _currentIndex = 0;
Timer? _rotationTimer;
String? _lastKeyword;
int? _lastRotationHours;
@override
void initState() {
super.initState();
_initDataAndTimer();
}
void _initDataAndTimer() async {
final settings = ref.read(settingsProvider);
_lastKeyword = settings.unsplashKeyword;
_lastRotationHours = settings.unsplashRotationHours;
await _fetchImages(settings.unsplashKeyword);
_startTimer(settings.unsplashRotationHours);
}
Future<void> _fetchImages(String keyword) async {
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(keyword);
if (urls.isNotEmpty && mounted) {
setState(() {
_urls = urls;
_currentIndex = 0;
});
}
}
void _startTimer(int hours) {
_rotationTimer?.cancel();
if (hours <= 0) return;
_rotationTimer = Timer.periodic(Duration(hours: hours), (_) {
if (_urls.isNotEmpty && mounted) {
setState(() {
_currentIndex = (_currentIndex + 1) % _urls.length;
});
}
});
}
@override
void dispose() {
_rotationTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
// Watch for config changes
if (settings.unsplashKeyword != _lastKeyword) {
_lastKeyword = settings.unsplashKeyword;
// Re-fetch images organically
_fetchImages(settings.unsplashKeyword);
}
if (settings.unsplashRotationHours != _lastRotationHours) {
_lastRotationHours = settings.unsplashRotationHours;
_startTimer(settings.unsplashRotationHours);
}
if (!settings.useUnsplashBackground || _urls.isEmpty) {
return const SizedBox.shrink(); // Fallback to flat background handled underneath
}
return AnimatedSwitcher(
duration: const Duration(seconds: 3),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Image.network(
_urls[_currentIndex],
key: ValueKey(_urls[_currentIndex]),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
// Soft opacity behind the MainScreen's dark glass vignette
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
);
}
}