Files
jamshalat-masjid-screen/lib/features/admin/admin_screen.dart
2026-03-30 22:49:40 +07:00

2645 lines
102 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import 'package:intl/intl.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/myquran_service.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
class AdminScreen extends ConsumerStatefulWidget {
const AdminScreen({super.key});
@override
ConsumerState<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen> {
final _masjidNameCtrl = TextEditingController();
final _masjidAddressCtrl = TextEditingController();
final _cityCtrl = TextEditingController(); // Displays DisplayName or CityID
final _mainDurCtrl = TextEditingController();
final _slideDurCtrl = TextEditingController();
int _selectedTab = 0;
bool _isSyncing = false;
int _textScaleIndex = 1;
List<String> _slideshowImages = [];
bool _useUnsplash = false;
final _unsplashKeywordCtrl = TextEditingController();
final _unsplashRotationCtrl = TextEditingController();
// Branded background
String? _brandedBgImage;
// Running text repeater
String _marqueeAnimType = 'marquee';
List<String> _runningTexts = [];
List<int> _runningTextDurations = [];
// Granular text group scales
double _scaleCardLabel = 1.0;
double _scaleCardBody = 1.0;
double _scaleRunningText = 1.0;
// Jumat fields
final _khatibCtrl = TextEditingController();
final _imamCtrl = TextEditingController();
// Iqomah Jeda fields
final _iqomahSubuhCtrl = TextEditingController();
final _iqomahDzuhurCtrl = TextEditingController();
final _iqomahAsharCtrl = TextEditingController();
final _iqomahMaghribCtrl = TextEditingController();
final _iqomahIsyaCtrl = TextEditingController();
final _preAdzanLeadCtrl = TextEditingController();
final _blankNormalCtrl = TextEditingController();
final _blankJumatCtrl = TextEditingController();
final _identityScrollController = ScrollController();
final _jadwalScrollController = ScrollController();
final _tampilanScrollController = ScrollController();
final _jumatScrollController = ScrollController();
final _simulasiScrollController = ScrollController();
int _hijriOffsetDays = 0;
@override
void initState() {
super.initState();
final settings = ref.read(settingsProvider);
_masjidNameCtrl.text = settings.masjidName;
_masjidAddressCtrl.text = settings.masjidAddress;
_cityCtrl.text = '${settings.cityDisplayName} (${settings.cityIdApi})';
_mainDurCtrl.text = settings.mainScreenDurationSec.toString();
_slideDurCtrl.text = settings.slideDurationSec.toString();
_textScaleIndex = settings.textScaleIndex;
_slideshowImages = List.from(settings.slideshowImages);
_useUnsplash = settings.useUnsplashBackground;
_unsplashKeywordCtrl.text = settings.unsplashKeyword;
_unsplashRotationCtrl.text = settings.unsplashRotationHours.toString();
_brandedBgImage = settings.brandedBgImage;
_marqueeAnimType = settings.marqueeAnimType;
_runningTexts = List.from(settings.runningTexts);
_runningTextDurations = List.from(
settings.runningTextDurations.isNotEmpty
? settings.runningTextDurations
: List.filled(settings.runningTexts.length, 12),
);
// Ensure durations list length matches texts
while (_runningTextDurations.length < _runningTexts.length) {
_runningTextDurations.add(12);
}
_scaleCardLabel = settings.scaleCardLabel;
_scaleCardBody = settings.scaleCardBody;
_scaleRunningText = settings.scaleRunningText;
_khatibCtrl.text = settings.khatibName;
_imamCtrl.text = settings.imamName;
_iqomahSubuhCtrl.text = settings.iqomahSubuh.toString();
_iqomahDzuhurCtrl.text = settings.iqomahDzuhur.toString();
_iqomahAsharCtrl.text = settings.iqomahAshar.toString();
_iqomahMaghribCtrl.text = settings.iqomahMaghrib.toString();
_iqomahIsyaCtrl.text = settings.iqomahIsya.toString();
_preAdzanLeadCtrl.text = settings.preAdzanLead.toString();
_blankNormalCtrl.text = settings.blankScreenNormal.toString();
_blankJumatCtrl.text = settings.blankScreenJumat.toString();
_hijriOffsetDays = settings.hijriOffsetDays;
// Update preview live as admin types
_khatibCtrl.addListener(() => setState(() {}));
_imamCtrl.addListener(() => setState(() {}));
}
@override
void dispose() {
_masjidNameCtrl.dispose();
_masjidAddressCtrl.dispose();
_cityCtrl.dispose();
_mainDurCtrl.dispose();
_slideDurCtrl.dispose();
_unsplashKeywordCtrl.dispose();
_unsplashRotationCtrl.dispose();
_khatibCtrl.dispose();
_imamCtrl.dispose();
_iqomahSubuhCtrl.dispose();
_iqomahDzuhurCtrl.dispose();
_iqomahAsharCtrl.dispose();
_iqomahMaghribCtrl.dispose();
_iqomahIsyaCtrl.dispose();
_preAdzanLeadCtrl.dispose();
_blankNormalCtrl.dispose();
_blankJumatCtrl.dispose();
_identityScrollController.dispose();
_jadwalScrollController.dispose();
_tampilanScrollController.dispose();
_jumatScrollController.dispose();
_simulasiScrollController.dispose();
super.dispose();
}
Future<void> _saveIdentity() async {
await ref.read(settingsProvider.notifier).updateSettings((s) {
s.masjidName = _masjidNameCtrl.text.trim();
s.masjidAddress = _masjidAddressCtrl.text.trim();
// cityId is saved instantly when selected from dialog
return s;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Pengaturan berhasil disimpan',
style: GoogleFonts.manrope()),
backgroundColor: SacredColors.primaryContainer,
),
);
}
}
Future<void> _saveTampilan() async {
await ref.read(settingsProvider.notifier).updateSettings((s) {
s.textScaleIndex = _textScaleIndex;
s.slideshowImages = List.from(_slideshowImages);
s.mainScreenDurationSec = int.tryParse(_mainDurCtrl.text.trim()) ?? 15;
s.slideDurationSec = int.tryParse(_slideDurCtrl.text.trim()) ?? 10;
s.useUnsplashBackground = _useUnsplash;
s.unsplashKeyword = _unsplashKeywordCtrl.text.trim().isEmpty ? 'mosque' : _unsplashKeywordCtrl.text.trim();
s.unsplashRotationHours = int.tryParse(_unsplashRotationCtrl.text.trim()) ?? 6;
s.brandedBgImage = _brandedBgImage;
s.runningTexts = List.from(_runningTexts);
s.runningTextDurations = List.from(_runningTextDurations);
s.marqueeAnimType = _marqueeAnimType;
s.scaleCardLabel = _scaleCardLabel;
s.scaleCardBody = _scaleCardBody;
s.scaleRunningText = _scaleRunningText;
return s;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Pengaturan Tampilan berhasil disimpan', style: GoogleFonts.manrope()),
backgroundColor: SacredColors.primaryContainer,
),
);
}
}
Future<void> _saveJadwalTimingSettings() async {
await ref.read(settingsProvider.notifier).updateSettings((s) {
s.preAdzanLead = int.tryParse(_preAdzanLeadCtrl.text.trim()) ?? 10;
s.blankScreenNormal = int.tryParse(_blankNormalCtrl.text.trim()) ?? 15;
s.blankScreenJumat = int.tryParse(_blankJumatCtrl.text.trim()) ?? 45;
s.iqomahSubuh = int.tryParse(_iqomahSubuhCtrl.text.trim()) ?? 15;
s.iqomahDzuhur = int.tryParse(_iqomahDzuhurCtrl.text.trim()) ?? 10;
s.iqomahAshar = int.tryParse(_iqomahAsharCtrl.text.trim()) ?? 10;
s.iqomahMaghrib = int.tryParse(_iqomahMaghribCtrl.text.trim()) ?? 10;
s.iqomahIsya = int.tryParse(_iqomahIsyaCtrl.text.trim()) ?? 10;
return s;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Pengaturan jadwal dan durasi berhasil disimpan',
style: GoogleFonts.manrope(),
),
backgroundColor: SacredColors.primaryContainer,
),
);
}
}
Future<void> _saveHijriSettings() async {
await ref.read(settingsProvider.notifier).updateSettings((s) {
s.hijriOffsetDays = _hijriOffsetDays;
return s;
});
if (mounted) {
ref.invalidate(hijriDateProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Offset Hijriah disimpan: ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari',
style: GoogleFonts.manrope(),
),
backgroundColor: SacredColors.primaryContainer,
),
);
}
}
Future<void> _syncData() async {
setState(() => _isSyncing = true);
final success = await SyncService.instance.syncMonthlyData();
setState(() => _isSyncing = false);
if (mounted) {
ref.invalidate(todayScheduleProvider);
ref.invalidate(scheduleCacheStatusProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success ? 'Sinkronisasi jadwal berhasil' : 'Sinkronisasi gagal. Periksa koneksi internet.',
style: GoogleFonts.manrope()),
backgroundColor: success ? SacredColors.primaryContainer : SacredColors.errorContainer,
),
);
}
}
Future<void> _showCitySearchDialog(double s) async {
final queryCtrl = TextEditingController();
List<Map<String, dynamic>> results = [];
bool isSearching = false;
await showDialog(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setDialogState) {
return Dialog(
backgroundColor: SacredColors.surfaceContainerLowest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.xl)),
child: Container(
width: 800 * s,
height: 600 * s,
padding: EdgeInsets.all(40 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cari Kota / Kabupaten',
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s, fontWeight: FontWeight.bold, color: SacredColors.primary)),
SizedBox(height: 24 * s),
Row(
children: [
Expanded(
child: TextField(
controller: queryCtrl,
style: GoogleFonts.manrope(fontSize: 24 * s, color: SacredColors.onSurface),
decoration: InputDecoration(
hintText: 'Misal: Yogyakarta',
filled: true,
fillColor: SacredColors.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.md)),
),
),
),
SizedBox(width: 16 * s),
ElevatedButton.icon(
onPressed: () async {
final query = queryCtrl.text.trim();
if (query.isEmpty) return;
setDialogState(() => isSearching = true);
final res = await MyQuranSholatService.instance.searchCity(query);
setDialogState(() {
results = res;
isSearching = false;
});
},
icon: isSearching
? SizedBox(width: 20*s, height: 20*s, child: const CircularProgressIndicator(color: SacredColors.onPrimary, strokeWidth: 2))
: const HugeIcon(icon: HugeIcons.strokeRoundedSearch01, color: SacredColors.onPrimary),
label: Text('CARI', style: GoogleFonts.plusJakartaSans(fontSize: 20*s, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.primary,
foregroundColor: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 24 * s),
),
),
],
),
SizedBox(height: 32 * s),
Expanded(
child: results.isEmpty && !isSearching
? Center(child: Text('Tidak ada hasil', style: GoogleFonts.manrope(fontSize: 20 * s, color: SacredColors.onSurfaceVariant)))
: ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final city = results[index];
return Padding(
padding: EdgeInsets.only(bottom: 8 * s),
child: ListTile(
title: Text(city['lokasi'] ?? '', style: GoogleFonts.plusJakartaSans(fontSize: 24 * s, color: SacredColors.onSurface)),
subtitle: Text('ID: ${city['id']}', style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.primary)),
tileColor: SacredColors.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.sm)),
contentPadding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
onTap: () async {
final id = city['id'].toString();
final loc = city['lokasi'].toString();
await ref.read(settingsProvider.notifier).updateSettings((s) {
s.cityIdApi = id;
s.cityDisplayName = loc;
return s;
});
if (!mounted || !ctx.mounted) return;
setState(() {
_cityCtrl.text = '$loc ($id)';
});
Navigator.pop(ctx);
},
),
);
},
),
),
],
),
),
);
}
);
},
);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
return Scaffold(
backgroundColor: SacredColors.background,
appBar: AppBar(
backgroundColor: SacredColors.surfaceContainerLowest,
title: Text(
'PENGATURAN SISTEM',
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
letterSpacing: 2 * s,
),
),
iconTheme: const IconThemeData(color: SacredColors.primary),
),
body: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nav rail area
Container(
width: 350 * s,
color: SacredColors.surfaceContainerLow,
padding: EdgeInsets.all(32 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_NavButton(
title: 'IDENTITAS MASJID',
icon: HugeIcons.strokeRoundedHome01,
isActive: _selectedTab == 0,
scale: s,
onTap: () => setState(() => _selectedTab = 0),
),
SizedBox(height: 16 * s),
_NavButton(
title: 'JADWAL & SINKRONISASI',
icon: HugeIcons.strokeRoundedCalendar01,
isActive: _selectedTab == 1,
scale: s,
onTap: () => setState(() => _selectedTab = 1),
),
SizedBox(height: 16 * s),
_NavButton(
title: 'TAMPILAN & MEDIA',
icon: HugeIcons.strokeRoundedImage01,
isActive: _selectedTab == 2,
scale: s,
onTap: () => setState(() => _selectedTab = 2),
),
SizedBox(height: 16 * s),
_NavButton(
title: 'PENGATURAN JUMAT',
icon: HugeIcons.strokeRoundedCalendar01,
isActive: _selectedTab == 3,
scale: s,
onTap: () => setState(() => _selectedTab = 3),
),
SizedBox(height: 16 * s),
_NavButton(
title: 'SIMULASI',
icon: HugeIcons.strokeRoundedClock01,
isActive: _selectedTab == 4,
scale: s,
onTap: () => setState(() => _selectedTab = 4),
),
],
),
),
// Content area
Expanded(
child: Padding(
padding: EdgeInsets.all(64 * s),
child: _selectedTab == 0
? _buildIdentityTab(s)
: _selectedTab == 1
? _buildJadwalTab(s)
: _selectedTab == 2
? _buildTampilanTab(s)
: _selectedTab == 3
? _buildJumatTab(s)
: _buildSimulasiTab(s),
),
),
],
),
),
);
}
Future<void> _saveJumat() async {
await ref.read(settingsProvider.notifier).updateSettings((s) {
s.khatibName = _khatibCtrl.text.trim();
s.imamName = _imamCtrl.text.trim();
return s;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Data Jumat berhasil disimpan', style: GoogleFonts.manrope()),
backgroundColor: SacredColors.primaryContainer,
),
);
}
}
Widget _buildJumatTab(double s) {
return SingleChildScrollView(
controller: _jumatScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pengaturan Jumat',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s, fontWeight: FontWeight.w700, color: SacredColors.secondary),
),
SizedBox(height: 8 * s),
Text(
'Data di bawah akan tampil setiap hari Jumat: pada layar utama (banner bawah jam) dan layar Persiapan Khutbah.',
style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 40 * s),
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Petugas Shalat Jumat', s),
SizedBox(height: 8 * s),
Text(
'Nama Khatib dan Imam tampil di layar utama setiap Jumat dan di layar Persiapan Khutbah.',
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 24 * s),
_buildTextField('Nama Khatib Minggu Ini', _khatibCtrl, s),
SizedBox(height: 16 * s),
_buildTextField('Nama Imam Minggu Ini', _imamCtrl, s),
SizedBox(height: 32 * s),
// Preview chip
if (_khatibCtrl.text.isNotEmpty || _imamCtrl.text.isNotEmpty) ...[
Text('Preview tampilan:', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)),
SizedBox(height: 10 * s),
Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: SacredColors.background,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(color: SacredColors.secondary.withValues(alpha: 0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, color: SacredColors.secondary, size: 16 * s),
SizedBox(width: 8 * s),
Text('JUMAT MUBARAK', style: GoogleFonts.plusJakartaSans(
fontSize: 14 * s, fontWeight: FontWeight.w800, color: SacredColors.secondary, letterSpacing: 2)),
SizedBox(width: 8 * s),
Icon(Icons.star_rounded, color: SacredColors.secondary, size: 16 * s),
SizedBox(width: 24 * s),
if (_khatibCtrl.text.isNotEmpty)
Text('KHATIB ${_khatibCtrl.text}', style: GoogleFonts.manrope(
fontSize: 14 * s, color: SacredColors.onSurface)),
if (_khatibCtrl.text.isNotEmpty && _imamCtrl.text.isNotEmpty)
Text(' | ', style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)),
if (_imamCtrl.text.isNotEmpty)
Text('IMAM ${_imamCtrl.text}', style: GoogleFonts.manrope(
fontSize: 14 * s, color: SacredColors.onSurface)),
],
),
),
SizedBox(height: 24 * s),
],
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _saveJumat,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary,
foregroundColor: Colors.black,
padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 20 * s),
textStyle: TextStyle(fontSize: 18 * s, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)),
),
icon: const Icon(Icons.save_rounded),
label: const Text('SIMPAN DATA JUMAT'),
),
),
],
)),
SizedBox(height: 32 * s),
// Info box
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Kapan Digunakan?', s),
SizedBox(height: 16 * s),
_infoRow(Icons.tv, 'Layar Utama (Jumat)', 'Banner bawah jam berubah ke JUMAT MUBARAK, nama khatib & imam tampil di bawahnya.', s),
SizedBox(height: 12 * s),
_infoRow(Icons.timer_outlined, 'Layar Persiapan Khutbah', 'Saat menuju iqomah Dzuhur di hari Jumat, layar menampilkan judul PERSIAPAN KHUTBAH beserta nama petugas.', s),
SizedBox(height: 12 * s),
_infoRow(Icons.info_outline, 'Durasi Blank Screen', 'Durasi Black Screen setelah shalat Jumat dapat diatur di tab Jadwal & Sinkronisasi.', s),
],
)),
],
),
);
}
Widget _infoRow(IconData icon, String title, String desc, double s) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: SacredColors.secondary, size: 22 * s),
SizedBox(width: 12 * s),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: GoogleFonts.manrope(fontSize: 15 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
SizedBox(height: 4 * s),
Text(desc, style: GoogleFonts.manrope(fontSize: 13 * s, color: SacredColors.onSurfaceVariant)),
],
),
),
],
);
}
Widget _buildTampilanTab(double s) {
return SingleChildScrollView(
controller: _tampilanScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pengaturan Tampilan & Media',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 48 * s),
// ── Row 1: General settings + Background ──
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left: Typography & Timers
Expanded(
child: _adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Tipografi & Skala Teks', s),
SizedBox(height: 12 * s),
_buildSegmentedControl(
s: s,
child: SegmentedButton<int>(
segments: const [
ButtonSegment(value: 0, label: Text('Kecil')),
ButtonSegment(value: 1, label: Text('Normal')),
ButtonSegment(value: 2, label: Text('Besar')),
],
selected: {_textScaleIndex},
onSelectionChanged: (val) {
setState(() => _textScaleIndex = val.first);
},
),
),
SizedBox(height: 28 * s),
_buildTvIntStepperField(
s: s,
label: 'Durasi Layar Utama',
controller: _mainDurCtrl,
fallback: 15,
min: 5,
max: 120,
suffix: 'detik',
fastStep: 10,
),
SizedBox(height: 24 * s),
_buildTvIntStepperField(
s: s,
label: 'Durasi Tiap Slideshow',
controller: _slideDurCtrl,
fallback: 10,
min: 5,
max: 120,
suffix: 'detik',
fastStep: 10,
),
SizedBox(height: 40 * s),
_sectionLabel('Ukuran Teks Per Kelompok', s),
SizedBox(height: 8 * s),
Text(
'Kontrol ukuran teks secara spesifik per kelompok, terlepas dari skala global di atas.',
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 20 * s),
_scaleSlider(
s: s,
label: 'Label Shalat (Nama: SUBUH, DZUHUR…)',
value: _scaleCardLabel,
onChanged: (v) => setState(() => _scaleCardLabel = v),
),
SizedBox(height: 16 * s),
_scaleSlider(
s: s,
label: 'Waktu & Iqamah pada kartu jadwal',
value: _scaleCardBody,
onChanged: (v) => setState(() => _scaleCardBody = v),
),
SizedBox(height: 16 * s),
_scaleSlider(
s: s,
label: 'Teks Berjalan (Running Text)',
value: _scaleRunningText,
onChanged: (v) => setState(() => _scaleRunningText = v),
),
SizedBox(height: 40 * s),
_sectionLabel('Background Layar Utama (Unsplash)', s),
SizedBox(height: 12 * s),
_buildSwitchTile(
s: s,
title: 'Gunakan Foto Unsplash API',
value: _useUnsplash,
onChanged: (val) => setState(() => _useUnsplash = val),
),
if (_useUnsplash) ...[
SizedBox(height: 12 * s),
_buildTextField('Kata Kunci (Contoh: mosque, architecture)', _unsplashKeywordCtrl, s),
SizedBox(height: 12 * s),
_buildTvIntStepperField(
s: s,
label: 'Rotasi Foto',
controller: _unsplashRotationCtrl,
fallback: 6,
min: 1,
max: 24,
suffix: 'jam',
),
],
SizedBox(height: 56 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _saveTampilan,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.primary,
foregroundColor: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 24 * s),
textStyle: TextStyle(fontSize: 18 * s, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)),
),
icon: HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk, color: SacredColors.onPrimary),
label: const Text('SIMPAN TAMPILAN'),
),
),
],
)),
),
SizedBox(width: 32 * s),
// Right: Branded Background + Slideshow
Expanded(
child: Column(
children: [
// Branded Background Card
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Foto Latar Utama (Branding Masjid)', s),
SizedBox(height: 16 * s),
if (_brandedBgImage != null && _brandedBgImage!.isNotEmpty) ...[
ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.md),
child: Image.file(
File(_brandedBgImage!),
height: 120 * s,
width: double.infinity,
fit: BoxFit.cover,
),
),
SizedBox(height: 12 * s),
Row(
children: [
Expanded(
child: Text(
_brandedBgImage!.split('/').last,
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 22 * s),
onPressed: () {
setState(() => _brandedBgImage = null);
_saveTampilan();
},
),
],
),
] else
Text('Belum ada foto latar masjid.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)),
SizedBox(height: 16 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image);
if (res != null && res.files.single.path != null) {
setState(() => _brandedBgImage = res.files.single.path);
_saveTampilan();
}
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s),
label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)),
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary,
foregroundColor: SacredColors.onSecondary,
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s),
),
),
),
],
)),
SizedBox(height: 24 * s),
// Slideshow Gallery Card
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_sectionLabel('Galeri Gambar Slideshow', s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
if (res != null) {
setState(() {
for (var path in res.paths) {
if (path != null && !_slideshowImages.contains(path)) {
_slideshowImages.add(path);
}
}
});
_saveTampilan();
}
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onSecondary, size: 18 * s),
label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)),
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary,
foregroundColor: SacredColors.onSecondary,
padding: EdgeInsets.symmetric(horizontal: 20 * s, vertical: 14 * s),
),
),
),
],
),
SizedBox(height: 16 * s),
if (_slideshowImages.isEmpty)
Text('Belum ada gambar slideshow.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant))
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _slideshowImages.length,
itemBuilder: (context, idx) {
final path = _slideshowImages[idx];
return ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.sm),
child: Image.file(File(path), width: 56 * s, height: 56 * s, fit: BoxFit.cover),
),
title: Text(path.split('/').last, maxLines: 1, overflow: TextOverflow.ellipsis,
style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurface)),
trailing: IconButton(
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 20 * s),
onPressed: () {
setState(() => _slideshowImages.removeAt(idx));
_saveTampilan();
},
),
);
},
),
],
)),
],
),
),
],
),
SizedBox(height: 40 * s),
// ── Row 2: Running Text Repeater ──
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_sectionLabel('Running Text / Pengumuman', s),
Row(
children: [
Text('Mode Animasi:', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)),
SizedBox(width: 12 * s),
_buildSegmentedControl(
s: s,
child: SegmentedButton<String>(
segments: [
ButtonSegment(value: 'marquee', label: Text('Marquee', style: GoogleFonts.manrope(fontSize: 16 * s))),
ButtonSegment(value: 'fade', label: Text('Fade In-Out', style: GoogleFonts.manrope(fontSize: 16 * s))),
],
selected: {_marqueeAnimType},
onSelectionChanged: (val) => setState(() => _marqueeAnimType = val.first),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) =>
states.contains(WidgetState.selected) ? SacredColors.primary : SacredColors.surfaceContainerLowest),
foregroundColor: WidgetStateProperty.resolveWith((states) =>
states.contains(WidgetState.selected) ? SacredColors.onPrimary : SacredColors.onSurfaceVariant),
),
),
),
],
),
],
),
SizedBox(height: 24 * s),
// Repeater list
if (_runningTexts.isEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: 16 * s),
child: Text('Belum ada teks. Klik TAMBAH untuk menambah baris.', style: GoogleFonts.manrope(fontSize: 16 * s, color: SacredColors.onSurfaceVariant)),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _runningTexts.length,
separatorBuilder: (_, __) => SizedBox(height: 12 * s),
itemBuilder: (context, idx) {
final textCtrl = TextEditingController(text: _runningTexts[idx])
..selection = TextSelection.fromPosition(TextPosition(offset: _runningTexts[idx].length));
final durCtrl = TextEditingController(text: _runningTextDurations[idx].toString());
return Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
child: Row(
children: [
Container(
width: 32 * s,
height: 32 * s,
alignment: Alignment.center,
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Text('${idx + 1}', style: GoogleFonts.manrope(fontSize: 14 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)),
),
SizedBox(width: 16 * s),
Expanded(
flex: 5,
child: TextField(
controller: textCtrl,
style: GoogleFonts.plusJakartaSans(fontSize: 20 * s, color: SacredColors.onSurface),
decoration: InputDecoration(
hintText: 'Teks pengumuman...',
filled: true,
fillColor: SacredColors.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.sm), borderSide: BorderSide.none),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 14 * s),
),
onChanged: (val) => _runningTexts[idx] = val,
),
),
SizedBox(width: 12 * s),
SizedBox(
width: 100 * s,
child: TextField(
controller: durCtrl,
keyboardType: TextInputType.number,
style: GoogleFonts.plusJakartaSans(fontSize: 20 * s, color: SacredColors.onSurface),
decoration: InputDecoration(
hintText: 'Detik',
filled: true,
fillColor: SacredColors.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(SacredRadii.sm), borderSide: BorderSide.none),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 14 * s),
suffixText: 'dtk',
),
onChanged: (val) => _runningTextDurations[idx] = int.tryParse(val) ?? 12,
),
),
SizedBox(width: 8 * s),
IconButton(
icon: HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: SacredColors.error, size: 22 * s),
onPressed: () {
setState(() {
_runningTexts.removeAt(idx);
_runningTextDurations.removeAt(idx);
});
},
),
],
),
);
},
),
SizedBox(height: 20 * s),
Row(
children: [
_tvActionButton(
s: s,
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_runningTexts.add('');
_runningTextDurations.add(12);
});
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.primary, size: 20 * s),
label: Text('TAMBAH BARIS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, color: SacredColors.primary)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: SacredColors.primary.withValues(alpha: 0.5)),
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 16 * s),
),
),
),
SizedBox(width: 16 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _saveTampilan,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.primary,
foregroundColor: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 16 * s),
),
icon: HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk, color: SacredColors.onPrimary, size: 18 * s),
label: Text('SIMPAN TEKS', style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.bold)),
),
),
],
),
],
)),
SizedBox(height: 40 * s),
],
),
);
}
Widget _adminCard(double s, {required Widget child}) {
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: _TvFocusFrame(
scale: s,
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(36 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)),
),
child: child,
),
),
);
}
Widget _tvFocusable({
required Widget child,
required double s,
double radius = SacredRadii.md,
bool scrollAware = true,
}) {
final framed = _TvFocusFrame(
scale: s,
borderRadius: BorderRadius.circular(radius),
child: child,
);
if (!scrollAware) return framed;
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: framed,
);
}
Widget _tvActionButton({
required Widget child,
required double s,
double radius = SacredRadii.lg,
}) {
return _tvFocusable(
child: child,
s: s,
radius: radius,
scrollAware: true,
);
}
Widget _buildReadonlyField(TextEditingController controller, double s) {
return _tvFocusable(
s: s,
child: TextField(
controller: controller,
readOnly: true,
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
color: SacredColors.onSurface,
),
decoration: InputDecoration(
filled: true,
fillColor: SacredColors.surfaceContainerLowest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(SacredRadii.md),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(SacredRadii.md),
borderSide: const BorderSide(color: SacredColors.primary, width: 2),
),
),
),
);
}
Widget _buildSwitchTile({
required double s,
required String title,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return _tvFocusable(
s: s,
radius: SacredRadii.md,
child: Container(
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
),
child: SwitchListTile(
title: Text(
title,
style: GoogleFonts.plusJakartaSans(
fontSize: 18 * s,
color: SacredColors.onSurface,
),
),
value: value,
onChanged: onChanged,
activeThumbColor: SacredColors.primary,
contentPadding: EdgeInsets.symmetric(horizontal: 12 * s),
),
),
);
}
Widget _buildSegmentedControl({
required double s,
required Widget child,
double radius = SacredRadii.md,
}) {
return _tvFocusable(
s: s,
radius: radius,
child: Container(
padding: EdgeInsets.all(6 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(radius),
),
child: child,
),
);
}
Widget _sectionLabel(String label, double s) {
return Text(
label,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
);
}
Widget _buildIdentityTab(double s) {
return SingleChildScrollView(
controller: _identityScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Identitas & Lokasi Masjid',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 48 * s),
Container(
width: 800 * s,
padding: EdgeInsets.all(40 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField('Nama Masjid', _masjidNameCtrl, s),
SizedBox(height: 32 * s),
_buildTextField('Alamat Lengkap', _masjidAddressCtrl, s, maxLines: 2),
SizedBox(height: 32 * s),
// City API Config
Text(
'Lokasi Jadwal Shalat (MyQuran API)',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 12 * s),
Row(
children: [
Expanded(
child: _buildReadonlyField(_cityCtrl, s),
),
SizedBox(width: 16 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: () => _showCitySearchDialog(s),
icon: HugeIcon(icon: HugeIcons.strokeRoundedSearch01, color: SacredColors.onPrimary),
label: Text('CARI KOTA', style: TextStyle(fontSize: 16 * s)),
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary,
foregroundColor: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s),
),
),
),
],
),
SizedBox(height: 64 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _saveIdentity,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.primary,
foregroundColor: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 24 * s),
textStyle: TextStyle(fontSize: 20 * s, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)),
),
icon: HugeIcon(icon: HugeIcons.strokeRoundedFloppyDisk, color: SacredColors.onPrimary),
label: const Text('SIMPAN PERUBAHAN TULISAN'),
),
),
],
),
),
],
),
);
}
Widget _buildJadwalTab(double s) {
final settings = ref.watch(settingsProvider);
final todayScheduleOption = ref.watch(todayScheduleProvider);
final cacheStatus = ref.watch(scheduleCacheStatusProvider);
final displayedHijri = ref.watch(hijriDateProvider).valueOrNull;
final cacheRangeLabel = cacheStatus.hasData
? '${_formatCacheDate(cacheStatus.startDate)} - ${_formatCacheDate(cacheStatus.endDate)}'
: 'Belum ada data';
return SingleChildScrollView(
controller: _jadwalScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jadwal & Sinkronisasi',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 48 * s),
// Sync Card
Container(
width: double.infinity,
padding: EdgeInsets.all(40 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status Data Jadwal',
style: GoogleFonts.manrope(fontSize: 20 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurfaceVariant, letterSpacing: 1 * s),
),
SizedBox(height: 24 * s),
Wrap(
spacing: 48 * s,
runSpacing: 20 * s,
children: [
_buildStatusRow('Terakhir Sync', settings.lastSyncDate ?? 'Belum pernah', HugeIcons.strokeRoundedClock01, s),
_buildStatusRow('Sumber Data', 'api.myquran.com', HugeIcons.strokeRoundedDatabase01, s),
_buildStatusRow('Lokasi Data', settings.cityDisplayName, HugeIcons.strokeRoundedLocation01, s),
_buildStatusRow('Cache Tersimpan', cacheRangeLabel, HugeIcons.strokeRoundedCalendar03, s),
_buildStatusRow('Jumlah Hari', cacheStatus.hasData ? '${cacheStatus.cachedDays} hari' : '0 hari', HugeIcons.strokeRoundedTaskDaily01, s),
_buildStatusRow('Status Update', _buildCacheUpdateLabel(cacheStatus, todayScheduleOption != null), HugeIcons.strokeRoundedAlert02, s),
],
),
],
),
),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _isSyncing ? null : _syncData,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary,
foregroundColor: SacredColors.onSecondary,
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 32 * s),
textStyle: TextStyle(fontSize: 20 * s, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)),
),
icon: _isSyncing
? SizedBox(width: 24*s, height: 24*s, child: const CircularProgressIndicator(color: SacredColors.onSecondary, strokeWidth: 3))
: HugeIcon(icon: HugeIcons.strokeRoundedCloudDownload, color: SacredColors.onSecondary),
label: Text(_isSyncing ? 'MENYINKRONKAN...' : 'SINKRONKAN DATA BULAN INI'),
),
)
],
),
),
SizedBox(height: 64 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Kalender Hijriah', s),
SizedBox(height: 8 * s),
Text(
'Sesuaikan tampilan tanggal Hijriah jika hasil rukyat lokal masjid berbeda dari nilai default API.',
style: GoogleFonts.manrope(
fontSize: 14 * s,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 24 * s),
Container(
width: double.infinity,
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tanggal tampil saat ini',
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 8 * s),
Text(
displayedHijri ?? 'Memuat tanggal Hijriah...',
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 16 * s,
vertical: 10 * s,
),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(SacredRadii.full),
),
child: Text(
'Offset ${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari',
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
),
],
),
),
SizedBox(height: 20 * s),
_buildHijriOffsetControl(s),
SizedBox(height: 16 * s),
Row(
children: [
_tvActionButton(
s: s,
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_hijriOffsetDays = 0;
});
},
icon: const Icon(Icons.refresh),
label: const Text('RESET OFFSET'),
),
),
SizedBox(width: 16 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _saveHijriSettings,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.primary,
foregroundColor: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(
horizontal: 28 * s,
vertical: 18 * s,
),
),
icon: const Icon(Icons.save_rounded),
label: const Text('SIMPAN OFFSET HIJRIAH'),
),
),
],
),
],
),
),
SizedBox(height: 64 * s),
// Waktu & Durasi Card
_adminCard(s, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Waktu & Durasi', s),
SizedBox(height: 8 * s),
Text(
'Seluruh pengaturan angka utama untuk alur jadwal ditangani dengan stepper agar nyaman dipakai dengan remote Android TV.',
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 32 * s),
Row(
children: [
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Pra-Adzan',
controller: _preAdzanLeadCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
),
),
SizedBox(width: 16 * s),
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Blank Screen Normal',
controller: _blankNormalCtrl,
fallback: 15,
min: 0,
max: 120,
suffix: 'menit',
),
),
SizedBox(width: 16 * s),
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Blank Screen Jumat',
controller: _blankJumatCtrl,
fallback: 45,
min: 0,
max: 180,
suffix: 'menit',
),
),
],
),
SizedBox(height: 28 * s),
Text(
'Jeda Waktu Iqamah (Menit)',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 8 * s),
Text(
'Tentukan durasi hitung mundur dari selesai Adzan hingga iqamah untuk tiap shalat fardhu.',
style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 24 * s),
Row(
children: [
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Iqamah Subuh',
controller: _iqomahSubuhCtrl,
fallback: 15,
min: 0,
max: 60,
suffix: 'menit',
),
),
SizedBox(width: 16 * s),
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Iqamah Dzuhur',
controller: _iqomahDzuhurCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
),
),
SizedBox(width: 16 * s),
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Iqamah Ashar',
controller: _iqomahAsharCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
),
),
],
),
SizedBox(height: 16 * s),
Row(
children: [
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Iqamah Maghrib',
controller: _iqomahMaghribCtrl,
fallback: 7,
min: 0,
max: 60,
suffix: 'menit',
),
),
SizedBox(width: 16 * s),
Expanded(
child: _buildTvIntStepperField(
s: s,
label: 'Iqamah Isya',
controller: _iqomahIsyaCtrl,
fallback: 10,
min: 0,
max: 60,
suffix: 'menit',
),
),
SizedBox(width: 16 * s),
Expanded(child: SizedBox()), // spacer
],
),
SizedBox(height: 32 * s),
_tvActionButton(
s: s,
child: ElevatedButton.icon(
onPressed: _saveJadwalTimingSettings,
style: ElevatedButton.styleFrom(
backgroundColor: SacredColors.secondary,
foregroundColor: Colors.black,
padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 20 * s),
textStyle: TextStyle(fontSize: 18 * s, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(SacredRadii.lg)),
),
icon: const Icon(Icons.timer),
label: const Text('SIMPAN PENGATURAN JADWAL'),
),
),
],
)),
SizedBox(height: 64 * s),
Text(
'Pratinjau Jadwal Hari Ini',
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 32 * s),
// Schedule Grid
Builder(
builder: (context) {
if (todayScheduleOption == null) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 24 * s),
child: Center(
child: Text('Data jadwal kosong. Silakan lakukan sinkronisasi.', style: GoogleFonts.manrope(fontSize: 24 * s, color: SacredColors.error)),
),
);
}
final prayerMap = {
'IMSAK': todayScheduleOption.imsak,
'SUBUH': todayScheduleOption.subuh,
'TERBIT': todayScheduleOption.terbit,
'DHUHA': todayScheduleOption.dhuha,
'DZUHUR': todayScheduleOption.dzuhur,
'ASHAR': todayScheduleOption.ashar,
'MAGHRIB': todayScheduleOption.maghrib,
'ISYA': todayScheduleOption.isya,
};
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 24 * s,
mainAxisSpacing: 24 * s,
childAspectRatio: 2.2, // wide rectangular Google Stitch cards
),
itemCount: prayerMap.length,
itemBuilder: (context, index) {
final key = prayerMap.keys.elementAt(index);
final time = prayerMap[key]!;
return _buildPrayerCard(key, time, s);
},
);
},
),
SizedBox(height: 32 * s),
],
),
);
}
Widget _buildHijriOffsetControl(double s) {
const minOffset = -3;
const maxOffset = 3;
final valueLabel =
'${_hijriOffsetDays >= 0 ? '+' : ''}$_hijriOffsetDays hari';
final progress = (_hijriOffsetDays - minOffset) / (maxOffset - minOffset);
return Container(
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.25),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Offset Hari Hijriah',
style: GoogleFonts.manrope(
fontSize: 15 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
),
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 14 * s,
vertical: 5 * s,
),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(SacredRadii.sm),
),
child: Text(
valueLabel,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
),
],
),
SizedBox(height: 14 * s),
Row(
children: [
_tvStepBtn(
s: s,
label: '',
onPressed: () {
setState(() {
_hijriOffsetDays =
(_hijriOffsetDays - 1).clamp(minOffset, maxOffset);
});
},
),
SizedBox(width: 10 * s),
Expanded(
child: Stack(
alignment: Alignment.centerLeft,
children: [
Container(
height: 6 * s,
decoration: BoxDecoration(
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(3 * s),
),
),
FractionallySizedBox(
widthFactor: progress.clamp(0.0, 1.0),
child: Container(
height: 6 * s,
decoration: BoxDecoration(
color: SacredColors.primary,
borderRadius: BorderRadius.circular(3 * s),
),
),
),
],
),
),
SizedBox(width: 10 * s),
_tvStepBtn(
s: s,
label: '+',
onPressed: () {
setState(() {
_hijriOffsetDays =
(_hijriOffsetDays + 1).clamp(minOffset, maxOffset);
});
},
),
],
),
SizedBox(height: 12 * s),
Row(
children: [
Text(
'Preset: ',
style: GoogleFonts.manrope(
fontSize: 12 * s,
color: SacredColors.onSurfaceVariant,
),
),
...[-2, -1, 0, 1, 2].map((offset) {
final isActive = _hijriOffsetDays == offset;
final label = '${offset >= 0 ? '+' : ''}$offset';
return Padding(
padding: EdgeInsets.only(right: 8 * s),
child: InkWell(
focusColor: SacredColors.primary.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.sm),
onTap: () => setState(() => _hijriOffsetDays = offset),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12 * s,
vertical: 6 * s,
),
decoration: BoxDecoration(
color: isActive
? SacredColors.primary
: SacredColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(SacredRadii.sm),
border: isActive
? null
: Border.all(
color: SacredColors.outlineVariant.withValues(
alpha: 0.3,
),
),
),
child: Text(
label,
style: GoogleFonts.manrope(
fontSize: 13 * s,
fontWeight: FontWeight.w600,
color: isActive
? SacredColors.onPrimary
: SacredColors.onSurfaceVariant,
),
),
),
),
);
}),
],
),
SizedBox(height: 6 * s),
Text(
'TV Remote: fokus ke tombol atau + lalu tekan OK untuk ubah satu hari.',
style: GoogleFonts.manrope(
fontSize: 11 * s,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildPrayerCard(String name, String time, double s) {
return Container(
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10 * s,
offset: Offset(0, 4 * s),
)
]
),
padding: EdgeInsets.symmetric(horizontal: 32 * s, vertical: 24 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name,
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
SizedBox(height: 8 * s),
Text(
time,
style: GoogleFonts.plusJakartaSans(
fontSize: 42 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
],
),
);
}
ScrollController _scrollControllerForTab(int tabIndex) {
switch (tabIndex) {
case 0:
return _identityScrollController;
case 1:
return _jadwalScrollController;
case 2:
return _tampilanScrollController;
case 3:
return _jumatScrollController;
case 4:
default:
return _simulasiScrollController;
}
}
Widget _scrollAware({
required ScrollController controller,
required Widget child,
}) {
return Builder(
builder: (context) {
return Focus(
onFocusChange: (hasFocus) {
if (!hasFocus || !controller.hasClients) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
Scrollable.ensureVisible(
context,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: 0.18,
);
}
});
},
child: child,
);
},
);
}
int _parseCtrlInt(TextEditingController ctrl, int fallback) {
return int.tryParse(ctrl.text.trim()) ?? fallback;
}
void _bumpCtrlInt(
TextEditingController ctrl, {
required int delta,
required int min,
required int max,
required int fallback,
}) {
final next = (_parseCtrlInt(ctrl, fallback) + delta).clamp(min, max);
setState(() {
ctrl.text = next.toString();
});
}
Widget _buildTvIntStepperField({
required double s,
required String label,
required TextEditingController controller,
required int fallback,
required int min,
required int max,
String suffix = '',
int fastStep = 5,
}) {
final value = _parseCtrlInt(controller, fallback);
final valueLabel = suffix.isEmpty ? '$value' : '$value $suffix';
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: _TvFocusFrame(
scale: s,
borderRadius: BorderRadius.circular(SacredRadii.md),
child: Container(
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.25),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: GoogleFonts.manrope(
fontSize: 15 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
),
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 14 * s,
vertical: 5 * s,
),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(SacredRadii.sm),
),
child: Text(
valueLabel,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
),
],
),
SizedBox(height: 14 * s),
Row(
children: [
_tvStepBtn(
s: s,
label: '',
onPressed: () => _bumpCtrlInt(
controller,
delta: -fastStep,
min: min,
max: max,
fallback: fallback,
),
),
SizedBox(width: 6 * s),
_tvStepBtn(
s: s,
label: '',
onPressed: () => _bumpCtrlInt(
controller,
delta: -1,
min: min,
max: max,
fallback: fallback,
),
),
SizedBox(width: 10 * s),
Expanded(
child: Container(
height: 6 * s,
decoration: BoxDecoration(
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(3 * s),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: ((value - min) / (max - min)).clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: SacredColors.primary,
borderRadius: BorderRadius.circular(3 * s),
),
),
),
),
),
SizedBox(width: 10 * s),
_tvStepBtn(
s: s,
label: '+',
onPressed: () => _bumpCtrlInt(
controller,
delta: 1,
min: min,
max: max,
fallback: fallback,
),
),
SizedBox(width: 6 * s),
_tvStepBtn(
s: s,
label: '++',
onPressed: () => _bumpCtrlInt(
controller,
delta: fastStep,
min: min,
max: max,
fallback: fallback,
),
),
],
),
],
),
),
),
);
}
Widget _buildTextField(String label, TextEditingController ctrl, double s, {int maxLines = 1}) {
return _scrollAware(
controller: _scrollControllerForTab(_selectedTab),
child: _TvFocusFrame(
scale: s,
borderRadius: BorderRadius.circular(SacredRadii.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 12 * s),
TextField(
controller: ctrl,
maxLines: maxLines,
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
color: SacredColors.onSurface,
),
decoration: InputDecoration(
filled: true,
fillColor: SacredColors.surfaceContainerLowest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(SacredRadii.md),
borderSide: BorderSide(color: SacredColors.outlineVariant.withValues(alpha: 0.5)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(SacredRadii.md),
borderSide: const BorderSide(color: SacredColors.primary, width: 2),
),
),
),
],
),
),
);
}
Widget _buildStatusRow(String label, String value, dynamic icon, double s) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(SacredRadii.sm),
),
child: HugeIcon(icon: icon, color: SacredColors.secondary, size: 24 * s),
),
SizedBox(width: 16 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: GoogleFonts.manrope(fontSize: 12 * s, color: SacredColors.onSurfaceVariant)),
Text(value, style: GoogleFonts.plusJakartaSans(fontSize: 18 * s, fontWeight: FontWeight.w600, color: SacredColors.onSurface)),
],
),
],
);
}
String _formatCacheDate(DateTime? date) {
if (date == null) return 'Belum ada';
return DateFormat('dd MMM yyyy').format(date);
}
String _buildCacheUpdateLabel(
ScheduleCacheStatus status,
bool hasTodayData,
) {
if (!status.hasData) return 'Belum ada cache';
if (!hasTodayData) return 'Hari ini belum tersimpan';
if (status.daysUntilRefresh < 0) {
return 'Lewat ${-status.daysUntilRefresh} hari';
}
if (status.daysUntilRefresh == 0) return 'Update hari ini';
if (status.daysUntilRefresh == 1) return '1 hari lagi';
return '${status.daysUntilRefresh} hari lagi';
}
Widget _scaleSlider({
required double s,
required String label,
required double value,
required ValueChanged<double> onChanged,
}) {
final pct = (value * 100).round();
const step = 0.05;
const presets = [0.75, 1.0, 1.25, 1.5];
return _tvFocusable(
s: s,
child: Container(
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.md),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(label, style: GoogleFonts.manrope(
fontSize: 15 * s, fontWeight: FontWeight.w500, color: SacredColors.onSurface)),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 14 * s, vertical: 5 * s),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(SacredRadii.sm),
),
child: Text('$pct%', style: GoogleFonts.manrope(
fontSize: 16 * s, fontWeight: FontWeight.w800, color: SacredColors.primary)),
),
],
),
SizedBox(height: 14 * s),
// TV-remote control row
Row(
children: [
_tvStepBtn(s: s, label: '', onPressed: () => onChanged((value - step * 4).clamp(0.5, 2.0))),
SizedBox(width: 6 * s),
_tvStepBtn(s: s, label: '', onPressed: () => onChanged((value - step).clamp(0.5, 2.0))),
SizedBox(width: 10 * s),
Expanded(
child: Stack(
alignment: Alignment.centerLeft,
children: [
Container(
height: 6 * s,
decoration: BoxDecoration(
color: SacredColors.outlineVariant.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(3 * s),
),
),
FractionallySizedBox(
widthFactor: ((value - 0.5) / 1.5).clamp(0.0, 1.0),
child: Container(
height: 6 * s,
decoration: BoxDecoration(
color: SacredColors.primary,
borderRadius: BorderRadius.circular(3 * s),
),
),
),
],
),
),
SizedBox(width: 10 * s),
_tvStepBtn(s: s, label: '+', onPressed: () => onChanged((value + step).clamp(0.5, 2.0))),
SizedBox(width: 6 * s),
_tvStepBtn(s: s, label: '++', onPressed: () => onChanged((value + step * 4).clamp(0.5, 2.0))),
],
),
SizedBox(height: 12 * s),
// Quick preset chips
Row(
children: [
Text('Cepat: ', style: GoogleFonts.manrope(fontSize: 12 * s, color: SacredColors.onSurfaceVariant)),
...presets.map((p) {
final isActive = (value - p).abs() < 0.02;
return Padding(
padding: EdgeInsets.only(right: 8 * s),
child: InkWell(
focusColor: SacredColors.primary.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(SacredRadii.sm),
onTap: () => onChanged(p),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12 * s, vertical: 6 * s),
decoration: BoxDecoration(
color: isActive ? SacredColors.primary : SacredColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(SacredRadii.sm),
border: isActive ? null : Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
child: Text('${(p * 100).round()}%', style: GoogleFonts.manrope(
fontSize: 13 * s, fontWeight: FontWeight.w600,
color: isActive ? SacredColors.onPrimary : SacredColors.onSurfaceVariant)),
),
),
);
}),
],
),
SizedBox(height: 6 * s),
Text(
'TV Remote: gunakan ↑↓ untuk pindah fokus, tekan OK pada /+ untuk mengubah nilai.',
style: GoogleFonts.manrope(fontSize: 11 * s, color: SacredColors.onSurfaceVariant.withValues(alpha: 0.7)),
),
],
),
),
);
}
Widget _tvStepBtn({required double s, required String label, required VoidCallback onPressed}) {
return _tvFocusable(
s: s,
radius: SacredRadii.sm,
scrollAware: false,
child: Material(
color: Colors.transparent,
child: InkWell(
focusColor: SacredColors.primary.withValues(alpha: 0.35),
hoverColor: SacredColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(SacredRadii.sm),
onTap: onPressed,
child: Container(
width: 42 * s,
height: 38 * s,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(SacredRadii.sm),
color: SacredColors.surfaceContainerHighest,
),
child: Text(
label,
style: GoogleFonts.manrope(
fontSize: 15 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
),
),
),
);
}
Widget _buildSimulasiTab(double s) {
return SingleChildScrollView(
controller: _simulasiScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode Simulasi Pengembang',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 16 * s),
Text(
'Gunakan tombol di bawah ini untuk melihat pratinjau bagaimana aplikasi bereaksi terhadap berbagai waktu dan status tanpa harus menunggu waktu sebenarnya.\nFitur ini bekerja dengan menggeser waktu aplikasi (Time Travel).',
style: GoogleFonts.manrope(fontSize: 18 * s, color: SacredColors.onSurfaceVariant),
),
SizedBox(height: 48 * s),
Wrap(
spacing: 24 * s,
runSpacing: 24 * s,
children: [
_simulasiCard(
s: s,
title: 'Reset Waktu Asli',
icon: HugeIcons.strokeRoundedHome01,
desc: 'Kembali ke waktu saat ini secara sinkron dengan jam sistem.',
onTap: () => _simulateTimeOffset(Duration.zero),
),
_simulasiCard(
s: s,
title: 'Menuju Adzan',
icon: HugeIcons.strokeRoundedClock01,
desc: 'Melompat ke 2 menit sebelum Adzan Dzuhur hari ini.',
onTap: () => _simulateEvent('pre_adzan'),
),
_simulasiCard(
s: s,
title: 'Selama Adzan',
icon: HugeIcons.strokeRoundedMegaphone01,
desc: 'Melompat ke tepat waktu Adzan Dzuhur berkumandang.',
onTap: () => _simulateEvent('adzan'),
),
_simulasiCard(
s: s,
title: 'Menuju Iqomah',
icon: HugeIcons.strokeRoundedTimer02,
desc: 'Melompat ke saat waktu iqomah sedang menghitung mundur (1 menit setelah Adzan).',
onTap: () => _simulateEvent('iqomah'),
),
_simulasiCard(
s: s,
title: 'Persiapan Jumat',
icon: HugeIcons.strokeRoundedCalendar03,
desc: 'Menyimulasikan layar khusus persiapan Jumat (30 menit sebelum Adzan Dzuhur).',
onTap: () => _simulateEvent('jumat_incoming'),
),
_simulasiCard(
s: s,
title: 'Khutbah Berlangsung',
icon: HugeIcons.strokeRoundedUserGroup,
desc: 'Menyimulasikan layar saat Khutbah sedang berlangsung tanpa hitungan mundur (2 menit setelah Adzan Dzuhur).',
onTap: () => _simulateEvent('jumat_khutbah'),
),
_simulasiCard(
s: s,
title: 'Mode Shalat',
icon: HugeIcons.strokeRoundedMoon02,
desc: 'Layar menjadi hitam atau gelap selama shalat berlangsung.',
onTap: () => _simulateEvent('shalat'),
),
],
),
],
),
);
}
Widget _simulasiCard({required double s, required String title, required dynamic icon, required String desc, required VoidCallback onTap}) {
return _tvFocusable(
s: s,
radius: SacredRadii.lg,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(SacredRadii.lg),
child: Container(
width: 320 * s,
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.5)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10 * s, offset: Offset(0, 4 * s)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
HugeIcon(icon: icon, color: SacredColors.primary, size: 40 * s),
SizedBox(height: 16 * s),
Text(title, style: GoogleFonts.plusJakartaSans(fontSize: 20 * s, fontWeight: FontWeight.bold, color: SacredColors.onSurface)),
SizedBox(height: 8 * s),
Text(desc, style: GoogleFonts.manrope(fontSize: 14 * s, color: SacredColors.onSurfaceVariant)),
],
),
),
),
);
}
void _simulateTimeOffset(Duration offset) {
ref.read(mockTimeOffsetProvider.notifier).state = offset;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Offset Waktu disetel ke: ${offset.inMinutes} Menit', style: GoogleFonts.manrope())),
);
}
void _simulateEvent(String eventType) {
final schedule = ref.read(todayScheduleProvider);
if (schedule == null) return;
// We simulate using schedule.dzuhur
final dzuhurStr = schedule.dzuhur;
final parts = dzuhurStr.split(':');
final realNow = DateTime.now();
final dzuhurTime = DateTime(realNow.year, realNow.month, realNow.day, int.parse(parts[0]), int.parse(parts[1]));
DateTime targetTime;
switch (eventType) {
case 'pre_adzan':
targetTime = dzuhurTime.subtract(const Duration(minutes: 2));
break;
case 'adzan':
targetTime = dzuhurTime;
break;
case 'iqomah':
targetTime = dzuhurTime.add(const Duration(seconds: 45)); // During iqomah
break;
case 'jumat_incoming':
int diff = DateTime.friday - realNow.weekday;
DateTime nextFriday = realNow.add(Duration(days: diff));
// Target: next Friday at dzuhur time - 30 minutes
targetTime = DateTime(nextFriday.year, nextFriday.month, nextFriday.day, dzuhurTime.hour, dzuhurTime.minute).subtract(const Duration(minutes: 30));
break;
case 'jumat_khutbah':
int diff = DateTime.friday - realNow.weekday;
DateTime nextFriday = realNow.add(Duration(days: diff));
// Target: next Friday at dzuhur time + 3 minutes (safely past 2-min Adzan)
targetTime = DateTime(nextFriday.year, nextFriday.month, nextFriday.day, dzuhurTime.hour, dzuhurTime.minute).add(const Duration(minutes: 3));
break;
case 'shalat':
// Shalat mode usually happens after iqomah ends
final settings = ref.read(settingsProvider);
targetTime = dzuhurTime.add(Duration(minutes: settings.iqomahDzuhur + 1));
break;
default:
targetTime = realNow;
}
final offset = targetTime.difference(realNow);
_simulateTimeOffset(offset);
}
}
class _NavButton extends StatefulWidget {
final String title;
final dynamic icon;
final bool isActive;
final double scale;
final VoidCallback onTap;
const _NavButton({
required this.title,
required this.icon,
required this.isActive,
required this.scale,
required this.onTap,
});
@override
State<_NavButton> createState() => _NavButtonState();
}
class _TvFocusFrame extends StatefulWidget {
final Widget child;
final double scale;
final BorderRadius borderRadius;
const _TvFocusFrame({
required this.child,
required this.scale,
required this.borderRadius,
});
@override
State<_TvFocusFrame> createState() => _TvFocusFrameState();
}
class _TvFocusFrameState extends State<_TvFocusFrame> {
bool _hasFocus = false;
@override
Widget build(BuildContext context) {
final s = widget.scale;
return Focus(
canRequestFocus: false,
descendantsAreFocusable: true,
onFocusChange: (value) {
if (_hasFocus != value) {
setState(() => _hasFocus = value);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(_hasFocus ? 4 * s : 0),
decoration: BoxDecoration(
color: _hasFocus
? SacredColors.primary.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: widget.borderRadius,
border: Border.all(
color: _hasFocus
? SacredColors.primary.withValues(alpha: 0.7)
: Colors.transparent,
width: _hasFocus ? 2 : 0,
),
boxShadow: _hasFocus
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.18),
blurRadius: 18 * s,
spreadRadius: 1 * s,
),
]
: null,
),
child: widget.child,
),
);
}
}
class _NavButtonState extends State<_NavButton> {
bool _isFocused = false;
@override
Widget build(BuildContext context) {
final s = widget.scale;
final highlight = widget.isActive || _isFocused;
return FocusableActionDetector(
onShowFocusHighlight: (value) => setState(() => _isFocused = value),
child: InkWell(
onTap: widget.onTap,
focusColor: SacredColors.primary.withValues(alpha: 0.22),
hoverColor: SacredColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(SacredRadii.lg),
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 24 * s),
decoration: BoxDecoration(
color: highlight ? SacredColors.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: highlight
? Border.all(
color: SacredColors.primary.withValues(alpha: 0.4),
width: _isFocused ? 2 : 1,
)
: null,
),
child: Row(
children: [
HugeIcon(
icon: widget.icon,
color: highlight
? SacredColors.onPrimaryContainer
: SacredColors.onSurfaceVariant,
size: 28 * s,
),
SizedBox(width: 20 * s),
Expanded(
child: Text(
widget.title,
style: GoogleFonts.plusJakartaSans(
fontSize: 18 * s,
fontWeight: FontWeight.bold,
color: highlight
? SacredColors.onPrimaryContainer
: SacredColors.onSurfaceVariant,
letterSpacing: 1 * s,
),
),
),
],
),
),
),
);
}
}