Files
jamshalat-diary/lib/features/quran/presentation/quran_reading_screen.dart
2026-03-22 19:37:49 +07:00

1506 lines
52 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:just_audio/just_audio.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/services/app_audio_player.dart';
import '../../../core/widgets/arabic_text.dart';
import '../../../core/widgets/bottom_sheet_content_padding.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/quran_bookmark.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/local/models/daily_worship_log.dart';
import '../../../data/local/models/shalat_log.dart';
import '../../../data/local/models/tilawah_log.dart';
import '../../../data/services/muslim_api_service.dart';
import '../../../core/providers/tilawah_tracking_provider.dart';
class QuranReadingScreen extends ConsumerStatefulWidget {
final String surahId;
final int? initialVerse;
final bool isSimpleModeTab;
const QuranReadingScreen({
super.key,
required this.surahId,
this.initialVerse,
this.isSimpleModeTab = false,
});
@override
ConsumerState<QuranReadingScreen> createState() => _QuranReadingScreenState();
}
class _QuranReadingScreenState extends ConsumerState<QuranReadingScreen> {
Map<String, dynamic>? _surah;
List<Map<String, dynamic>> _verses = [];
bool _loading = true;
bool _autoSyncEnabled = false;
String _targetUnit = 'Juz';
final ScrollController _scrollController = ScrollController();
final Map<int, GlobalKey> _verseKeys = {};
final AudioPlayer _audioPlayer = AppAudioPlayer.instance;
int? _playingVerseIndex;
bool _isAudioLoading = false;
// Display Settings
bool _showLatin = true;
bool _showTerjemahan = true;
double _arabicFontSize = 24.0;
// Hafalan State
bool _isHafalanMode = false;
int _hafalanStartAyat = 1;
int _hafalanEndAyat = 1;
int _hafalanLoopCount = 1; // 0 = Tak Terbatas
int _currentLoop = 0;
bool _isHafalanPlaying = false;
StreamSubscription? _playerStateSubscription;
void _navigateToMurattal() {
if (widget.isSimpleModeTab) {
context.push('/quran/${widget.surahId}/murattal');
} else {
context.push('/tools/quran/${widget.surahId}/murattal');
}
}
@override
void initState() {
super.initState();
_loadSurah();
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
if (!widget.isSimpleModeTab && !settings.tilawahAutoSync) {
settings.tilawahAutoSync = true;
if (settings.isInBox) {
settings.save();
} else {
settingsBox.put('default', settings);
}
}
_autoSyncEnabled = !widget.isSimpleModeTab || settings.tilawahAutoSync;
_targetUnit = settings.tilawahTargetUnit;
_showLatin = settings.showLatin;
_showTerjemahan = settings.showTerjemahan;
_arabicFontSize = settings.arabicFontSize;
_playerStateSubscription = _audioPlayer.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
if (mounted) {
if (_isHafalanMode &&
_isHafalanPlaying &&
_playingVerseIndex != null) {
_handleHafalanNext();
} else {
setState(() {
_playingVerseIndex = null;
_isAudioLoading = false;
});
}
}
}
});
}
void _handleHafalanNext() {
// Current verse index is 0-based. EndAyat is 1-based.
final endIdx = _hafalanEndAyat - 1;
final startIdx = _hafalanStartAyat - 1;
if (_playingVerseIndex! < endIdx) {
// Move to next ayat in sequence
final nextIdx = _playingVerseIndex! + 1;
final audioUrl = _resolveVerseAudioUrl(_verses[nextIdx]);
if (audioUrl != null) {
_playAudio(nextIdx, audioUrl);
_scrollToVerse(nextIdx, attempts: 0);
} else {
_stopHafalan();
}
} else {
// Reached the end of the sequence. Loop or Stop.
_currentLoop++;
if (_hafalanLoopCount == 0 || _currentLoop < _hafalanLoopCount) {
// Loop again!
final audioUrl = _resolveVerseAudioUrl(_verses[startIdx]);
if (audioUrl != null) {
_playAudio(startIdx, audioUrl);
_scrollToVerse(startIdx, attempts: 0);
} else {
_stopHafalan();
}
} else {
// Finished all loops
_stopHafalan();
}
}
}
void _stopHafalan({bool isDisposing = false}) {
_audioPlayer.stop();
if (isDisposing) return;
if (mounted) {
setState(() {
_isHafalanPlaying = false;
_playingVerseIndex = null;
_isAudioLoading = false;
_currentLoop = 0;
});
}
}
@override
void dispose() {
_playerStateSubscription?.cancel();
if (_playingVerseIndex != null || _isHafalanPlaying || _isAudioLoading) {
_stopHafalan(isDisposing: true);
}
_scrollController.dispose();
super.dispose();
}
Future<void> _loadSurah() async {
final surahNum = int.tryParse(widget.surahId) ?? 1;
final data = await MuslimApiService.instance.getSurah(surahNum);
if (!mounted) return;
if (data != null) {
setState(() {
_surah = data;
final ayatList = List<Map<String, dynamic>>.from(data['ayat'] ?? []);
_verses = ayatList;
_verseKeys.clear();
for (int i = 0; i < ayatList.length; i++) {
_verseKeys[i] = GlobalKey();
}
_loading = false;
});
_scrollToInitialVerse();
} else {
setState(() => _loading = false);
}
}
void _scrollToInitialVerse() {
if (widget.initialVerse != null && widget.initialVerse! > 0) {
final targetIndex = widget.initialVerse! - 1;
if (targetIndex >= 0 && targetIndex < _verses.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToVerse(targetIndex, attempts: 0);
});
}
}
}
void _scrollToVerse(int targetIndex, {required int attempts}) {
if (!mounted) return;
final key = _verseKeys[targetIndex];
if (key != null && key.currentContext != null) {
Scrollable.ensureVisible(
key.currentContext!,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
alignment: 0.08,
);
return;
}
if (attempts < 6) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToVerse(targetIndex, attempts: attempts + 1);
});
}
}
String? _resolveVerseAudioUrl(Map<String, dynamic> verse,
{String preferredQariId = '05'}) {
final rawAudio = verse['audio'];
if (rawAudio is Map) {
final preferred = rawAudio[preferredQariId];
final preferredUrl = _coerceAudioUrl(preferred);
if (preferredUrl != null) {
return _normalizePlayableUrl(preferredUrl);
}
for (final value in rawAudio.values) {
final resolved = _coerceAudioUrl(value);
if (resolved != null) {
return _normalizePlayableUrl(resolved);
}
}
return null;
}
final direct = _coerceAudioUrl(rawAudio);
if (direct != null) {
return _normalizePlayableUrl(direct);
}
return null;
}
String? _coerceAudioUrl(dynamic rawValue) {
if (rawValue == null) return null;
if (rawValue is String) {
final trimmed = rawValue.trim();
if (trimmed.isEmpty || trimmed.toLowerCase() == 'null') return null;
// Backward-compat for previously cached map-to-string payloads.
if (trimmed.startsWith('{') && trimmed.contains(':')) {
final match = RegExp(r'https?://[^,\s}\]]+').firstMatch(trimmed);
if (match != null && match.groupCount >= 0) {
final mappedUrl = match.group(0);
if (mappedUrl != null && mappedUrl.isNotEmpty) {
return mappedUrl;
}
}
}
return trimmed;
}
if (rawValue is Map) {
final url = _coerceAudioUrl(rawValue['url']);
if (url != null) return url;
final src = _coerceAudioUrl(rawValue['src']);
if (src != null) return src;
final audio = _coerceAudioUrl(rawValue['audio']);
if (audio != null) return audio;
}
return null;
}
String _normalizePlayableUrl(String rawUrl) {
final trimmed = rawUrl.trim();
if (trimmed.startsWith('//')) {
return 'https:$trimmed';
}
if (trimmed.startsWith('http://')) {
return 'https://${trimmed.substring(7)}';
}
if (trimmed.startsWith('/')) {
return 'https://muslim.backoffice.biz.id$trimmed';
}
return trimmed;
}
Future<void> _playAudio(int verseIndex, String audioUrl) async {
if (_playingVerseIndex == verseIndex) {
await _audioPlayer.stop();
if (mounted) {
setState(() {
_playingVerseIndex = null;
_isAudioLoading = false;
});
}
return;
}
if (mounted) {
setState(() {
_playingVerseIndex = verseIndex;
_isAudioLoading = true;
});
}
try {
final verse = _verses[verseIndex];
final verseNumber = (verse['nomorAyat'] ?? verseIndex + 1) as int;
final surahName = _surah?['namaLatin'] ?? 'Surah ${widget.surahId}';
await _audioPlayer.setAudioSource(
AudioSource.uri(
Uri.parse(audioUrl),
tag: MediaItem(
id: 'ayah_${widget.surahId}_$verseNumber',
album: "Al-Qur'an Ayat",
title: 'Surah $surahName • Ayat $verseNumber',
artist: 'Tilawah',
),
),
);
if (mounted && _playingVerseIndex == verseIndex) {
setState(() {
_isAudioLoading = false;
});
}
unawaited(
_audioPlayer.play().catchError((error, st) {
debugPrint('Ayat audio playback error: $error');
debugPrint('$st');
if (!mounted) return;
setState(() {
_playingVerseIndex = null;
_isAudioLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Gagal memutar audio ayat'),
backgroundColor: Colors.red.shade400,
),
);
}),
);
} catch (e, st) {
debugPrint('Ayat audio load failed: $audioUrl');
debugPrint('Ayat audio error: $e');
debugPrint('$st');
if (mounted) {
setState(() {
_playingVerseIndex = null;
_isAudioLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Gagal memuat audio ayat ini'),
backgroundColor: Colors.red.shade400,
),
);
}
}
}
Future<void> _showBookmarkOptions(int verseIndex) async {
if (_surah == null || verseIndex >= _verses.length) return;
final verse = _verses[verseIndex];
final surahId = _surah!['nomor'] ?? 1;
final verseId = verseIndex + 1;
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 8, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
ListTile(
leading: const Icon(LucideIcons.pin, color: AppColors.primary),
title: const Text('Tandai Terakhir Dibaca',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text(
'Jadikan ayat ini sebagai titik lanjut membaca anda'),
onTap: () {
Navigator.pop(ctx);
_saveBookmark(surahId, verseId, verse, isLastRead: true);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.heart, color: Colors.pink),
title: const Text('Tambah ke Favorit',
style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Simpan ayat ini ke daftar favorit anda'),
onTap: () {
Navigator.pop(ctx);
_saveBookmark(surahId, verseId, verse, isLastRead: false);
},
),
const SizedBox(height: 8),
],
),
),
);
}
Future<Map<String, dynamic>?> _getTafsirByVerse({
required int surahId,
required int verseNumber,
}) async {
final tafsirItems =
await MuslimApiService.instance.getTafsirBySurah(surahId);
for (final item in tafsirItems) {
if ((item['nomorAyat'] as num?)?.toInt() == verseNumber) {
final wajiz = _sanitizeEnrichmentText(item['wajiz']);
final tahlili = _sanitizeEnrichmentText(item['tahlili']);
if (wajiz != null || tahlili != null) {
return {
...item,
'wajiz': wajiz ?? '',
'tahlili': tahlili ?? '',
};
}
return null;
}
}
return null;
}
String? _sanitizeEnrichmentText(dynamic raw) {
final text = raw?.toString().trim() ?? '';
if (text.isEmpty) return null;
final normalized = text.toLowerCase();
const invalidValues = <String>{
'0',
'-',
'',
'null',
'undefined',
'n/a',
'[]',
'{}',
};
if (invalidValues.contains(normalized)) return null;
return text;
}
void _showTafsirDrawer({
required int surahId,
required int verseNumber,
}) {
final surahName = _surah?['namaLatin']?.toString() ?? 'Surah $surahId';
final future =
_getTafsirByVerse(surahId: surahId, verseNumber: verseNumber);
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
final isDark = Theme.of(ctx).brightness == Brightness.dark;
return FractionallySizedBox(
heightFactor: 0.76,
child: FutureBuilder<Map<String, dynamic>?>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Gagal memuat tafsir ayat ini.',
textAlign: TextAlign.center,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
);
}
final data = snapshot.data;
final wajiz = data?['wajiz']?.toString().trim() ?? '';
final tahlili = data?['tahlili']?.toString().trim() ?? '';
final hasContent = wajiz.isNotEmpty || tahlili.isNotEmpty;
return SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
margin: const EdgeInsets.only(top: 10, bottom: 10),
width: 40,
height: 4,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight)
.withValues(alpha: 0.3),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Row(
children: [
const Icon(LucideIcons.fileText, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'Tafsir • $surahName : $verseNumber',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: !hasContent
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Tafsir untuk ayat ini belum tersedia.',
textAlign: TextAlign.center,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
)
: ListView(
padding:
const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
if (wajiz.isNotEmpty) ...[
Text(
'Tafsir Wajiz',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
letterSpacing: 0.4,
),
),
const SizedBox(height: 8),
Text(
wajiz,
style: TextStyle(
fontSize: 14,
height: 1.6,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
),
const SizedBox(height: 16),
],
if (tahlili.isNotEmpty) ...[
Text(
'Tafsir Tahlili',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
letterSpacing: 0.4,
),
),
const SizedBox(height: 8),
Text(
tahlili,
style: TextStyle(
fontSize: 14,
height: 1.6,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
),
],
],
),
),
],
),
);
},
),
);
},
);
}
Future<void> _saveBookmark(
int surahId, int verseId, Map<String, dynamic> verse,
{required bool isLastRead}) async {
final box = Hive.box<QuranBookmark>(HiveBoxes.bookmarks);
// If setting as Last Read, we must clear any prior Last Read flags globally
if (isLastRead) {
final keysToDelete = <dynamic>[];
for (final key in box.keys) {
final b = box.get(key);
if (b != null && b.isLastRead) {
keysToDelete.add(key);
}
}
await box.deleteAll(keysToDelete);
}
// Save the new bookmark
final bookmark = QuranBookmark(
surahId: surahId,
verseId: verseId,
surahName: _surah!['namaLatin'] ?? _surah!['nama'] ?? '',
verseText: verse['teksArab'] ?? '',
savedAt: DateTime.now(),
isLastRead: isLastRead,
verseLatin: verse['teksLatin'],
verseTranslation: verse['teksIndonesia'],
);
// Create a unique key. If favoriting an ayat that's already favorite, it overwrites.
// If the ayat is LastRead, we give it a special key so it can coexist with a favorite copy if they want.
final keySuffix = isLastRead ? '_lastread' : '';
await box.put('${surahId}_$verseId$keySuffix', bookmark);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isLastRead
? 'Disimpan sebagai Terakhir Dibaca'
: 'Disimpan ke Favorit'),
backgroundColor: isLastRead ? AppColors.primary : Colors.pink,
duration: const Duration(seconds: 1),
),
);
}
}
Future<void> _showEndTrackingDialog(
TilawahSession session, int endVerseId) async {
final endSurahId = _surah!['nomor'] ?? 1;
final endSurahName = _surah!['namaLatin'] ?? '';
int calculatedAyat = 0;
if (session.startSurahId == endSurahId) {
// Same surah
calculatedAyat = (endVerseId - session.startVerseId).abs() + 1;
} else {
// Cross surah calculation
final allSurahs = await MuslimApiService.instance.getAllSurahs();
if (allSurahs.isNotEmpty) {
int startSurahIdx =
allSurahs.indexWhere((s) => s['nomor'] == session.startSurahId);
int endSurahIdx = allSurahs.indexWhere((s) => s['nomor'] == endSurahId);
if (startSurahIdx < 0 || endSurahIdx < 0) {
calculatedAyat = (endVerseId - session.startVerseId).abs() + 1;
} else {
// Ensure chronological calculation
if (startSurahIdx > endSurahIdx) {
final tempIdx = startSurahIdx;
startSurahIdx = endSurahIdx;
endSurahIdx = tempIdx;
}
final startSurahData = allSurahs[startSurahIdx];
final int totalAyatInStart =
(startSurahData['jumlahAyat'] as num?)?.toInt() ?? 1;
calculatedAyat += (totalAyatInStart - session.startVerseId) +
1; // Ayats inside StartSurah
for (int i = startSurahIdx + 1; i < endSurahIdx; i++) {
calculatedAyat += (allSurahs[i]['jumlahAyat'] as int? ??
0); // Intermediate Surahs
}
calculatedAyat += endVerseId; // Ayats inside EndSurah
}
} else {
calculatedAyat = 1; // Fallback
}
}
if (!mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Catat Sesi Tilawah',
style: TextStyle(fontWeight: FontWeight.bold)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Mulai:', style: TextStyle(fontSize: 13)),
Text(
'${session.startSurahName} : ${session.startVerseId}',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13)),
]),
const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Divider()),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Selesai:', style: TextStyle(fontSize: 13)),
Text('$endSurahName : $endVerseId',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 13)),
]),
])),
const SizedBox(height: 16),
Row(
children: [
const Icon(LucideIcons.bookOpen,
size: 20, color: AppColors.primary),
const SizedBox(width: 8),
Text('Total Dibaca: $calculatedAyat Ayat',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15)),
],
),
],
),
actions: [
TextButton(
onPressed: () {
ref.invalidate(tilawahTrackingProvider);
Navigator.pop(ctx);
},
child: const Text('Batal', style: TextStyle(color: Colors.red)),
),
FilledButton(
onPressed: () {
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
var log = logBox.get(todayKey);
if (log == null) {
log = DailyWorshipLog(
date: todayKey,
shalatLogs: <String, ShalatLog>{
'subuh': ShalatLog(),
'dzuhur': ShalatLog(),
'ashar': ShalatLog(),
'maghrib': ShalatLog(),
'isya': ShalatLog(),
},
);
logBox.put(todayKey, log);
}
if (log.tilawahLog == null) {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
log.tilawahLog = TilawahLog(
targetValue: settings.tilawahTargetValue,
targetUnit: settings.tilawahTargetUnit,
autoSync: _autoSyncEnabled,
);
}
log.tilawahLog!.rawAyatRead += calculatedAyat;
log.save();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$calculatedAyat Ayat dicatat!'),
backgroundColor: AppColors.primary,
duration: const Duration(seconds: 2),
),
);
}
ref.invalidate(tilawahTrackingProvider);
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
),
);
}
void _showDisplaySettings() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: bottomSheetContentPadding(context),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Tampilan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Tampilkan Latin'),
value: _showLatin,
activeColor: AppColors.primary,
onChanged: (val) {
setModalState(() => _showLatin = val);
setState(() => _showLatin = val);
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
settings.showLatin = val;
settings.save();
},
),
SwitchListTile(
title: const Text('Tampilkan Terjemahan'),
value: _showTerjemahan,
activeColor: AppColors.primary,
onChanged: (val) {
setModalState(() => _showTerjemahan = val);
setState(() => _showTerjemahan = val);
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
settings.showTerjemahan = val;
settings.save();
},
),
const SizedBox(height: 8),
const Text('Ukuran Font Arab'),
Slider(
value: _arabicFontSize,
min: 16,
max: 40,
divisions: 12,
label: '${_arabicFontSize.round()}pt',
activeColor: AppColors.primary,
onChanged: (val) {
setModalState(() => _arabicFontSize = val);
setState(() => _arabicFontSize = val);
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
settings.arabicFontSize = val;
settings.save();
},
),
const SizedBox(height: 16),
],
),
);
},
),
);
}
Widget _buildVerseSeparator({required bool isDark}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: Row(
children: [
Expanded(
child: Divider(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
LucideIcons.gem,
size: 10,
color: AppColors.primary.withValues(alpha: 0.3),
),
),
Expanded(
child: Divider(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
],
),
);
}
Widget _buildBismillahSection({required bool isDark}) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Column(
children: [
ArabicText(
'بِسْمِ اللّٰهِ الرَّحْمٰنِ الرَّحِيْمِ',
baseFontSize: 26,
fontWeight: FontWeight.w400,
),
const SizedBox(height: 4),
Text(
'"Dengan nama Allah Yang Maha Pengasih, Maha Penyayang."',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
);
}
Widget _buildVerseItem({
required int verseIndex,
required bool isDark,
required TilawahSession? trackingSession,
required bool isLastRead,
required bool isFav,
}) {
final verse = _verses[verseIndex];
final surahId = _surah!['nomor'] ?? 1;
final verseId = (verse['nomorAyat'] ?? (verseIndex + 1)) as int;
final isPlayingThis = _playingVerseIndex == verseIndex;
final isHighlighted = isLastRead || isPlayingThis;
return RepaintBoundary(
child: Container(
key: _verseKeys[verseIndex],
color: isHighlighted
? AppColors.primary.withValues(alpha: 0.1)
: Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$verseId',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
),
const Spacer(),
Builder(
builder: (context) {
final audioUrl = _resolveVerseAudioUrl(verse);
final isPlayingThis = _playingVerseIndex == verseIndex;
return IconButton(
onPressed: (audioUrl != null && audioUrl.isNotEmpty)
? () => _playAudio(verseIndex, audioUrl)
: null,
icon: isPlayingThis
? (_isAudioLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
)
: Icon(
LucideIcons.stopCircle,
color: AppColors.primary,
size: 24,
))
: Icon(
LucideIcons.playCircle,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
size: 20,
),
);
},
),
IconButton(
onPressed: () => _showTafsirDrawer(
surahId: surahId,
verseNumber: verseId,
),
tooltip: 'Tafsir ayat',
icon: Icon(
LucideIcons.fileText,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
size: 20,
),
),
if (_autoSyncEnabled)
IconButton(
onPressed: () {
if (trackingSession == null) {
ref
.read(tilawahTrackingProvider.notifier)
.startTracking(
surahId: _surah!['nomor'] ?? 1,
surahName: _surah!['namaLatin'] ?? '',
verseId: verse['nomorAyat'] ?? (verseIndex + 1),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sesi Tilawah dimulai'),
backgroundColor: AppColors.primary,
duration: Duration(seconds: 1),
),
);
} else {
_showEndTrackingDialog(
trackingSession,
verse['nomorAyat'] ?? (verseIndex + 1),
);
}
},
icon: Icon(
trackingSession == null
? LucideIcons.flag
: LucideIcons.stopCircle,
color: trackingSession == null
? (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight)
: Colors.red,
size: 20,
),
),
IconButton(
onPressed: () => _showBookmarkOptions(verseIndex),
icon: Icon(
isLastRead
? LucideIcons.pin
: (isFav ? LucideIcons.heart : LucideIcons.bookmark),
color: isLastRead
? AppColors.primary
: (isFav
? Colors.pink
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight)),
size: 20,
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ArabicText(
verse['teksArab'] ?? '',
textAlign: TextAlign.right,
baseFontSize: 26,
fontWeight: FontWeight.w400,
height: 2.0,
),
),
if (_showLatin) ...[
const SizedBox(height: 8),
Text(
verse['teksLatin'] ?? '',
style: const TextStyle(
fontSize: 13,
fontStyle: FontStyle.italic,
color: AppColors.primary,
),
),
],
if (_showTerjemahan) ...[
const SizedBox(height: 8),
Text(
verse['teksIndonesia'] ?? '',
style: TextStyle(
fontSize: 14,
height: 1.6,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
],
),
),
);
}
@override
Widget build(BuildContext context) {
final trackingSession = ref.watch(tilawahTrackingProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
final needsSystemBottomInset = !_isHafalanMode;
final totalVerses = _verses.length;
final surahName = _surah?['namaLatin'] ?? 'Memuat...';
final surahArti = _surah?['arti'] ?? '';
final tempatTurun = _surah?['tempatTurun'] ?? '';
final showBismillah = (_surah?['nomor'] ?? 1) != 9;
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
surahName,
textAlign: TextAlign.start,
),
if (totalVerses > 0)
Text(
'$surahArti$totalVerses AYAT • $tempatTurun'.toUpperCase(),
textAlign: TextAlign.start,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
color: AppColors.primary,
),
),
],
),
actions: [
IconButton(
icon: const Icon(LucideIcons.headphones),
tooltip: 'Murattal Surah',
onPressed: _navigateToMurattal,
),
IconButton(
icon: Icon(
LucideIcons.brain,
color: _isHafalanMode
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
tooltip: 'Mode Hafalan',
onPressed: () {
setState(() {
_isHafalanMode = !_isHafalanMode;
if (!_isHafalanMode) {
_stopHafalan();
}
});
},
),
IconButton(
icon: const Icon(LucideIcons.settings2),
onPressed: _showDisplaySettings,
),
],
),
body: SafeArea(
top: false,
bottom: needsSystemBottomInset,
child: _loading
? const Center(child: CircularProgressIndicator())
: _verses.isEmpty
? const Center(child: Text('Tidak dapat memuat surah'))
: Column(
children: [
// Progress bar
LinearProgressIndicator(
value: 1.0,
backgroundColor:
AppColors.primary.withValues(alpha: 0.1),
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.primary),
minHeight: 3,
),
// Verse list
Expanded(
child: ValueListenableBuilder(
valueListenable:
Hive.box<QuranBookmark>(HiveBoxes.bookmarks)
.listenable(),
builder: (context, Box<QuranBookmark> box, _) {
final favoriteKeys = <String>{};
final lastReadKeys = <String>{};
for (final entry in box.toMap().entries) {
final key = entry.key.toString();
final bookmark = entry.value;
if (bookmark.isLastRead) {
lastReadKeys.add(key);
} else {
favoriteKeys.add(key);
}
}
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.fromLTRB(
0,
16,
0,
needsSystemBottomInset ? 28 : 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showBismillah) ...[
_buildBismillahSection(isDark: isDark),
const SizedBox(height: 8),
],
for (int verseIndex = 0;
verseIndex < _verses.length;
verseIndex++) ...[
if (showBismillah || verseIndex > 0)
_buildVerseSeparator(isDark: isDark),
_buildVerseItem(
verseIndex: verseIndex,
isDark: isDark,
trackingSession: trackingSession,
isLastRead: lastReadKeys.contains(
'${_surah!['nomor']}_${(_verses[verseIndex]['nomorAyat'] ?? (verseIndex + 1)) as int}_lastread',
),
isFav: favoriteKeys.contains(
'${_surah!['nomor']}_${(_verses[verseIndex]['nomorAyat'] ?? (verseIndex + 1)) as int}',
),
),
],
],
),
);
},
),
),
],
),
),
bottomNavigationBar: _isHafalanMode ? _buildHafalanControlBar() : null,
);
}
Widget _buildHafalanControlBar() {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Ensure logical bounds just in case
if (_hafalanStartAyat > _verses.length) _hafalanStartAyat = _verses.length;
if (_hafalanEndAyat < _hafalanStartAyat)
_hafalanEndAyat = _hafalanStartAyat;
if (_hafalanEndAyat > _verses.length) _hafalanEndAyat = _verses.length;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
offset: const Offset(0, -4),
blurRadius: 16,
),
],
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Mode Hafalan',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
if (_isHafalanPlaying && _hafalanLoopCount > 0)
Text(
'Loop: ${_currentLoop + 1} / $_hafalanLoopCount',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
)
else if (_isHafalanPlaying)
Text(
'Loop: ${_currentLoop + 1} / ∞',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Start Ayat
_buildHafalanDropdown<int>(
label: 'Mulai',
value: _hafalanStartAyat,
items: List.generate(_verses.length, (i) => i + 1),
onChanged: _isHafalanPlaying
? null
: (val) {
setState(() {
_hafalanStartAyat = val!;
if (_hafalanEndAyat < val) _hafalanEndAyat = val;
});
},
),
const Text('-', style: TextStyle(color: Colors.grey)),
// End Ayat
_buildHafalanDropdown<int>(
label: 'Sampai',
value: _hafalanEndAyat,
items: List.generate(_verses.length - _hafalanStartAyat + 1,
(i) => _hafalanStartAyat + i),
onChanged: _isHafalanPlaying
? null
: (val) => setState(() => _hafalanEndAyat = val!),
),
const SizedBox(width: 8),
// Loop Count
_buildHafalanDropdown<int>(
label: 'Ulangi',
value: _hafalanLoopCount,
items: [1, 3, 5, 7, 0], // 0 = infinite
displayMap: {0: '', 1: '1x', 3: '3x', 5: '5x', 7: '7x'},
onChanged: _isHafalanPlaying
? null
: (val) => setState(() => _hafalanLoopCount = val!),
),
const SizedBox(width: 16),
// Play/Stop Button
GestureDetector(
onTap: () {
if (_isHafalanPlaying) {
_stopHafalan();
} else {
setState(() {
_isHafalanPlaying = true;
_currentLoop = 0;
});
final startIdx = _hafalanStartAyat - 1;
final audioUrl = _resolveVerseAudioUrl(_verses[startIdx]);
if (audioUrl != null) {
_playAudio(startIdx, audioUrl);
_scrollToVerse(startIdx, attempts: 0);
}
}
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Icon(
_isHafalanPlaying ? LucideIcons.square : LucideIcons.play,
color: Colors.white,
size: 28,
),
),
),
],
),
],
),
),
);
}
Widget _buildHafalanDropdown<T>({
required String label,
required T value,
required List<T> items,
required void Function(T?)? onChanged,
Map<T, String>? displayMap,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 4),
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color:
isDark ? AppColors.backgroundDark : AppColors.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.2),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<T>(
value: value,
items: items.map((e) {
return DropdownMenuItem<T>(
value: e,
child: Text(
displayMap != null ? displayMap[e]! : e.toString(),
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold),
),
);
}).toList(),
onChanged: onChanged,
icon: const Icon(LucideIcons.chevronDown, size: 16),
isDense: true,
borderRadius: BorderRadius.circular(12),
),
),
),
],
);
}
}