1044 lines
41 KiB
Dart
1044 lines
41 KiB
Dart
import 'dart:async';
|
|
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 '../../../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/tilawah_log.dart';
|
|
import '../../../data/services/equran_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 = AudioPlayer();
|
|
int? _playingVerseIndex;
|
|
bool _isAudioLoading = false;
|
|
|
|
// Display Settings
|
|
bool _showLatin = true;
|
|
bool _showTerjemahan = true;
|
|
|
|
// 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();
|
|
_autoSyncEnabled = settings.tilawahAutoSync;
|
|
_targetUnit = settings.tilawahTargetUnit;
|
|
_showLatin = settings.showLatin;
|
|
_showTerjemahan = settings.showTerjemahan;
|
|
|
|
_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 = _verses[nextIdx]['audio']?['05'] as String?;
|
|
if (audioUrl != null) {
|
|
_playAudio(nextIdx, audioUrl);
|
|
_attemptScroll(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 = _verses[startIdx]['audio']?['05'] as String?;
|
|
if (audioUrl != null) {
|
|
_playAudio(startIdx, audioUrl);
|
|
_attemptScroll(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();
|
|
_stopHafalan(isDisposing: true);
|
|
_audioPlayer.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadSurah() async {
|
|
final surahNum = int.tryParse(widget.surahId) ?? 1;
|
|
final data = await EQuranService.instance.getSurah(surahNum);
|
|
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) {
|
|
// Wait for next frame so the UI finishes building the keys
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_attemptScroll(targetIndex, attempts: 0);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _attemptScroll(int targetIndex, {required int attempts}) {
|
|
if (!mounted) return;
|
|
|
|
final key = _verseKeys[targetIndex];
|
|
if (key != null && key.currentContext != null) {
|
|
// It's built! Scroll directly to it.
|
|
Scrollable.ensureVisible(
|
|
key.currentContext!,
|
|
duration: const Duration(milliseconds: 400),
|
|
curve: Curves.easeInOut,
|
|
alignment: 0.1, // Aligns slightly below the very top of the screen
|
|
);
|
|
} else if (attempts < 15) {
|
|
// It's not built yet. We manually nudge the scroll window.
|
|
if (_scrollController.hasClients) {
|
|
int firstBuiltIndex = -1;
|
|
for (int i = 0; i < _verses.length; i++) {
|
|
if (_verseKeys[i]?.currentContext != null) {
|
|
firstBuiltIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
final currentOffset = _scrollController.offset;
|
|
final isScrollingUp = firstBuiltIndex != -1 && targetIndex < firstBuiltIndex;
|
|
final scrollAmount = isScrollingUp ? -1000.0 : 1000.0;
|
|
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final newOffset = (currentOffset + scrollAmount).clamp(0.0, maxScroll);
|
|
|
|
_scrollController.jumpTo(newOffset);
|
|
|
|
// Wait a frame for items to build, then try again
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_attemptScroll(targetIndex, attempts: attempts + 1);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
await _audioPlayer.setUrl(audioUrl);
|
|
_audioPlayer.play();
|
|
if (mounted) {
|
|
setState(() {
|
|
_isAudioLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
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,
|
|
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<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 EQuranService.instance.getAllSurahs();
|
|
if (allSurahs.isNotEmpty) {
|
|
int startSurahIdx = allSurahs.indexWhere((s) => s['nomor'] == session.startSurahId);
|
|
int endSurahIdx = allSurahs.indexWhere((s) => s['nomor'] == endSurahId);
|
|
|
|
// 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);
|
|
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: settings.tilawahAutoSync,
|
|
);
|
|
}
|
|
|
|
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,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (context, setModalState) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
|
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: 16),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final trackingSession = ref.watch(tilawahTrackingProvider);
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final totalVerses = _verses.length;
|
|
final surahName = _surah?['namaLatin'] ?? 'Memuat...';
|
|
final surahArti = _surah?['arti'] ?? '';
|
|
final tempatTurun = _surah?['tempatTurun'] ?? '';
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
children: [
|
|
Text(surahName),
|
|
if (totalVerses > 0)
|
|
Text(
|
|
'$surahArti • $totalVerses AYAT • $tempatTurun'.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 1.2,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
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: _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,
|
|
),
|
|
// Bismillah (skip for At-Tawbah)
|
|
if ((_surah?['nomor'] ?? 1) != 9)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Column(
|
|
children: [
|
|
const Text(
|
|
'بِسْمِ اللّٰهِ الرَّحْمٰنِ الرَّحِيْمِ',
|
|
style: TextStyle(
|
|
fontFamily: 'Amiri',
|
|
fontSize: 26,
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Verse list
|
|
Expanded(
|
|
child: ListView.separated(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
itemCount: _verses.length,
|
|
separatorBuilder: (_, __) => 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
itemBuilder: (context, i) {
|
|
final verse = _verses[i];
|
|
final surahId = _surah!['nomor'] ?? 1;
|
|
final verseId = (verse['nomorAyat'] ?? (i + 1)) as int;
|
|
final lastReadKey = '${surahId}_${verseId}_lastread';
|
|
final favKey = '${surahId}_$verseId';
|
|
|
|
return ValueListenableBuilder(
|
|
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
|
builder: (context, box, _) {
|
|
final isLastRead = box.containsKey(lastReadKey);
|
|
final isFav = box.containsKey(favKey);
|
|
final isPlayingThis = _playingVerseIndex == i;
|
|
final isHighlighted = isLastRead || isPlayingThis;
|
|
|
|
return Container(
|
|
key: _verseKeys[i],
|
|
color: isHighlighted
|
|
? AppColors.primary.withValues(alpha: 0.1)
|
|
: Colors.transparent,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Action row
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary
|
|
.withValues(alpha: 0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${verse['nomorAyat'] ?? i + 1}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Builder(
|
|
builder: (context) {
|
|
final audioUrl = verse['audio']?['05'] as String?;
|
|
final isPlayingThis = _playingVerseIndex == i;
|
|
return IconButton(
|
|
onPressed: (audioUrl != null && audioUrl.isNotEmpty)
|
|
? () => _playAudio(i, 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),
|
|
);
|
|
}
|
|
),
|
|
if (_autoSyncEnabled)
|
|
IconButton(
|
|
onPressed: () {
|
|
if (trackingSession == null) {
|
|
ref.read(tilawahTrackingProvider.notifier).startTracking(
|
|
surahId: _surah!['nomor'] ?? 1,
|
|
surahName: _surah!['namaLatin'] ?? '',
|
|
verseId: verse['nomorAyat'] ?? (i + 1),
|
|
);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Sesi Tilawah dimulai'),
|
|
backgroundColor: AppColors.primary,
|
|
duration: Duration(seconds: 1),
|
|
),
|
|
);
|
|
} else {
|
|
_showEndTrackingDialog(trackingSession, verse['nomorAyat'] ?? (i + 1));
|
|
}
|
|
},
|
|
icon: Icon(
|
|
trackingSession == null
|
|
? LucideIcons.flag
|
|
: LucideIcons.stopCircle,
|
|
color: trackingSession == null
|
|
? (isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight)
|
|
: Colors.red,
|
|
size: 20),
|
|
),
|
|
IconButton(
|
|
onPressed: () => _showBookmarkOptions(i),
|
|
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),
|
|
// Arabic text
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Text(
|
|
verse['teksArab'] ?? '',
|
|
textAlign: TextAlign.right,
|
|
style: const TextStyle(
|
|
fontFamily: 'Amiri',
|
|
fontSize: 26,
|
|
height: 2.0,
|
|
),
|
|
),
|
|
),
|
|
if (_showLatin) ...[
|
|
const SizedBox(height: 8),
|
|
// Latin transliteration
|
|
Text(
|
|
verse['teksLatin'] ?? '',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontStyle: FontStyle.italic,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
],
|
|
if (_showTerjemahan) ...[
|
|
const SizedBox(height: 8),
|
|
// Indonesian translation
|
|
Text(
|
|
verse['teksIndonesia'] ?? '',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
height: 1.6,
|
|
color: isDark
|
|
? AppColors.textSecondaryDark
|
|
: AppColors.textSecondaryLight,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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 = _verses[startIdx]['audio']?['05'] as String?;
|
|
if (audioUrl != null) {
|
|
_playAudio(startIdx, audioUrl);
|
|
_attemptScroll(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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|