feat: Murattal player enhancements & prayer schedule auto-scroll
- Murattal: Spotify-style 5-button controls [Shuffle, Prev, Play, Next, Playlist] - Murattal: Animated 7-bar equalizer visualization in player circle - Murattal: Unsplash API background with frosted glass player overlay - Murattal: Transparent AppBar with backdrop blur - Murattal: Surah playlist bottom sheet with full 114 Surah list - Murattal: Auto-play disabled on screen open, enabled on navigation - Murattal: Shuffle mode for random Surah playback - Murattal: Photographer attribution per Unsplash guidelines - Dashboard: Auto-scroll prayer schedule to next active prayer - Fix: setState lifecycle errors on Reading & Murattal screens - Setup: flutter_dotenv, cached_network_image, url_launcher deps
This commit is contained in:
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
@@ -0,0 +1,557 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../dashboard/data/prayer_times_provider.dart';
|
||||
|
||||
class ImsakiyahScreen extends ConsumerStatefulWidget {
|
||||
const ImsakiyahScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ImsakiyahScreen> createState() => _ImsakiyahScreenState();
|
||||
}
|
||||
|
||||
class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
int _selectedMonthIndex = 0;
|
||||
late List<_MonthOption> _months;
|
||||
late AppSettings _settings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = box.get('default') ?? AppSettings();
|
||||
_months = _generateMonths();
|
||||
// Find current month
|
||||
final now = DateTime.now();
|
||||
for (int i = 0; i < _months.length; i++) {
|
||||
if (_months[i].month == now.month && _months[i].year == now.year) {
|
||||
_selectedMonthIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<_MonthOption> _generateMonths() {
|
||||
final now = DateTime.now();
|
||||
final list = <_MonthOption>[];
|
||||
for (int offset = -2; offset <= 3; offset++) {
|
||||
final date = DateTime(now.year, now.month + offset, 1);
|
||||
list.add(_MonthOption(
|
||||
label: DateFormat('MMMM yyyy').format(date),
|
||||
month: date.month,
|
||||
year: date.year,
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||
final selected = _months[_selectedMonthIndex];
|
||||
final daysInMonth =
|
||||
DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final rows = <_DayRow>[];
|
||||
|
||||
for (int d = 1; d <= daysInMonth; d++) {
|
||||
final date = DateTime(selected.year, selected.month, d);
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
|
||||
if (apiData != null && apiData.containsKey(dateStr)) {
|
||||
final times = apiData[dateStr]!;
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: times['subuh'] ?? '-',
|
||||
sunrise: times['terbit'] ?? '-',
|
||||
dhuhr: times['dzuhur'] ?? '-',
|
||||
asr: times['ashar'] ?? '-',
|
||||
maghrib: times['maghrib'] ?? '-',
|
||||
isha: times['isya'] ?? '-',
|
||||
));
|
||||
} else {
|
||||
final times =
|
||||
PrayerService.instance.getPrayerTimes(-6.2088, 106.8456, date);
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: DateFormat('HH:mm').format(times.fajr),
|
||||
sunrise: DateFormat('HH:mm').format(times.sunrise),
|
||||
dhuhr: DateFormat('HH:mm').format(times.dhuhr),
|
||||
asr: DateFormat('HH:mm').format(times.asr),
|
||||
maghrib: DateFormat('HH:mm').format(times.maghrib),
|
||||
isha: DateFormat('HH:mm').format(times.isha),
|
||||
));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _showLocationDialog(BuildContext context) {
|
||||
final searchCtrl = TextEditingController();
|
||||
bool isSearching = false;
|
||||
List<Map<String, dynamic>> results = [];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cth: Jakarta',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
if (searchCtrl.text.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) async {
|
||||
if (val.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, i) {
|
||||
final city = results[i];
|
||||
return ListTile(
|
||||
title: Text(city['lokasi'] ?? ''),
|
||||
onTap: () {
|
||||
final id = city['id'];
|
||||
final name = city['lokasi'];
|
||||
if (id != null && name != null) {
|
||||
_settings.lastCityName = '$name|$id';
|
||||
_settings.save();
|
||||
|
||||
// Update providers to refresh data
|
||||
ref.invalidate(selectedCityIdProvider);
|
||||
ref.invalidate(cityNameProvider);
|
||||
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final today = DateTime.now();
|
||||
|
||||
final selectedMonth = _months[_selectedMonthIndex];
|
||||
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final cityNameAsync = ref.watch(cityNameProvider);
|
||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Kalender Sholat'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Month Selector ──
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
itemCount: _months.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final isSelected = i == _selectedMonthIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedMonthIndex = i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_months[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? AppColors.onPrimary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Location Card ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showLocationDialog(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on,
|
||||
color: AppColors.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lokasi Anda',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.expand_more,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Table Header ──
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_headerCell('TGL', flex: 4),
|
||||
_headerCell('SUBUH', flex: 3),
|
||||
_headerCell('SYURUQ', flex: 3),
|
||||
_headerCell('DZUHUR', flex: 3),
|
||||
_headerCell('ASHAR', flex: 3),
|
||||
_headerCell('MAGH', flex: 3),
|
||||
_headerCell('ISYA', flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Table Body ──
|
||||
Expanded(
|
||||
child: monthlyDataAsync.when(
|
||||
data: (apiData) {
|
||||
final rows = _createRows(apiData);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Day column
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) {
|
||||
final rows = _createRows(null); // fallback
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerCell(String text, {int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dataCell(String value, bool isToday,
|
||||
{bool bold = false, int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: bold ? FontWeight.w700 : FontWeight.w400,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthOption {
|
||||
final String label;
|
||||
final int month;
|
||||
final int year;
|
||||
_MonthOption({required this.label, required this.month, required this.year});
|
||||
}
|
||||
|
||||
class _DayRow {
|
||||
final DateTime date;
|
||||
final String fajr, sunrise, dhuhr, asr, maghrib, isha;
|
||||
_DayRow({
|
||||
required this.date,
|
||||
required this.fajr,
|
||||
required this.sunrise,
|
||||
required this.dhuhr,
|
||||
required this.asr,
|
||||
required this.maghrib,
|
||||
required this.isha,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user