- 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
558 lines
21 KiB
Dart
558 lines
21 KiB
Dart
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,
|
|
});
|
|
}
|