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:
0
lib/features/dashboard/data/.gitkeep
Normal file
0
lib/features/dashboard/data/.gitkeep
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/location_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Represents a single prayer time entry.
|
||||
class PrayerTimeEntry {
|
||||
final String name;
|
||||
final String time; // "HH:mm"
|
||||
final bool isActive;
|
||||
PrayerTimeEntry({
|
||||
required this.name,
|
||||
required this.time,
|
||||
this.isActive = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Full day prayer schedule from myQuran API.
|
||||
class DaySchedule {
|
||||
final String cityName;
|
||||
final String province;
|
||||
final String date; // yyyy-MM-dd
|
||||
final String tanggal; // formatted date from API
|
||||
final Map<String, String> times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
|
||||
DaySchedule({
|
||||
required this.cityName,
|
||||
required this.province,
|
||||
required this.date,
|
||||
required this.tanggal,
|
||||
required this.times,
|
||||
});
|
||||
|
||||
/// Is this schedule for tomorrow?
|
||||
bool get isTomorrow {
|
||||
final todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
return date.compareTo(todayStr) > 0;
|
||||
}
|
||||
|
||||
/// Get prayer time entries as a list.
|
||||
List<PrayerTimeEntry> get prayerList {
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('HH:mm');
|
||||
final currentTime = formatter.format(now);
|
||||
|
||||
final prayers = [
|
||||
PrayerTimeEntry(name: 'Imsak', time: times['imsak'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Subuh', time: times['subuh'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Terbit', time: times['terbit'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dhuha', time: times['dhuha'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dzuhur', time: times['dzuhur'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Ashar', time: times['ashar'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Maghrib', time: times['maghrib'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Isya', time: times['isya'] ?? '-'),
|
||||
];
|
||||
|
||||
// Find the next prayer
|
||||
int activeIndex = -1;
|
||||
if (isTomorrow) {
|
||||
// User specifically requested to show tomorrow's Subuh as upcoming
|
||||
activeIndex = 1; // 0=Imsak, 1=Subuh
|
||||
} else {
|
||||
for (int i = 0; i < prayers.length; i++) {
|
||||
if (prayers[i].time != '-' && prayers[i].time.compareTo(currentTime) > 0) {
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeIndex >= 0) {
|
||||
prayers[activeIndex] = PrayerTimeEntry(
|
||||
name: prayers[activeIndex].name,
|
||||
time: prayers[activeIndex].time,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
return prayers;
|
||||
}
|
||||
|
||||
/// Get the next prayer name and time.
|
||||
PrayerTimeEntry? get nextPrayer {
|
||||
final list = prayerList;
|
||||
for (final p in list) {
|
||||
if (p.isActive) return p;
|
||||
}
|
||||
// If none active and it's today, all prayers have passed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Jakarta city ID from myQuran API.
|
||||
const _defaultCityId = '58a2fc6ed39fd083f55d4182bf88826d';
|
||||
|
||||
/// Provider for the user's selected city ID (stored in Hive settings).
|
||||
final selectedCityIdProvider = StateProvider<String>((ref) {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').last;
|
||||
}
|
||||
return _defaultCityId;
|
||||
});
|
||||
|
||||
/// Provider for today's prayer times using myQuran API.
|
||||
final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
DaySchedule? schedule;
|
||||
|
||||
// Try API first
|
||||
final jadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, today);
|
||||
|
||||
if (jadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: jadwal['tanggal'] ?? today,
|
||||
times: jadwal,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if all prayers today have passed
|
||||
if (schedule != null && !schedule.isTomorrow && schedule.nextPrayer == null) {
|
||||
// All prayers passed, fetch tomorrow's schedule
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
||||
|
||||
final tmrwJadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
if (tmrwJadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: tomorrowStr,
|
||||
tanggal: tmrwJadwal['tanggal'] ?? tomorrowStr,
|
||||
times: tmrwJadwal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schedule != null) {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
// Fallback to adhan package
|
||||
final position = await LocationService.instance.getCurrentLocation();
|
||||
double lat = position?.latitude ?? -6.2088;
|
||||
double lng = position?.longitude ?? 106.8456;
|
||||
|
||||
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
if (result != null) {
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
return DaySchedule(
|
||||
cityName: 'Jakarta',
|
||||
province: 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||
times: {
|
||||
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||
'subuh': timeFormat.format(result.fajr),
|
||||
'terbit': timeFormat.format(result.sunrise),
|
||||
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||
'dzuhur': timeFormat.format(result.dhuhr),
|
||||
'ashar': timeFormat.format(result.asr),
|
||||
'maghrib': timeFormat.format(result.maghrib),
|
||||
'isya': timeFormat.format(result.isha),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||
final monthlyScheduleProvider =
|
||||
FutureProvider.family<Map<String, Map<String, String>>, String>(
|
||||
(ref, month) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
return MyQuranSholatService.instance.getMonthlySchedule(cityId, month);
|
||||
});
|
||||
|
||||
/// Provider for current city name.
|
||||
final cityNameProvider = FutureProvider<String>((ref) async {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
if (info != null) {
|
||||
return '${info['kabko']}, ${info['prov']}';
|
||||
}
|
||||
return 'Kota Jakarta, DKI Jakarta';
|
||||
});
|
||||
0
lib/features/dashboard/domain/.gitkeep
Normal file
0
lib/features/dashboard/domain/.gitkeep
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
@@ -0,0 +1,673 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/prayer_time_card.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../data/prayer_times_provider.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Timer? _countdownTimer;
|
||||
Duration _countdown = Duration.zero;
|
||||
String _nextPrayerName = '';
|
||||
final ScrollController _prayerScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
_prayerScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown(DaySchedule schedule) {
|
||||
_countdownTimer?.cancel();
|
||||
_updateCountdown(schedule);
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_updateCountdown(schedule);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCountdown(DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
if (next != null && next.time != '-') {
|
||||
final parts = next.time.split(':');
|
||||
if (parts.length == 2) {
|
||||
final now = DateTime.now();
|
||||
var target = DateTime(now.year, now.month, now.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]));
|
||||
if (target.isBefore(now)) {
|
||||
target = target.add(const Duration(days: 1));
|
||||
}
|
||||
setState(() {
|
||||
_nextPrayerName = next.name;
|
||||
_countdown = target.difference(now);
|
||||
if (_countdown.isNegative) _countdown = Duration.zero;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatCountdown(Duration d) {
|
||||
final h = d.inHours.toString().padLeft(2, '0');
|
||||
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final prayerTimesAsync = ref.watch(prayerTimesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildHeader(context, isDark),
|
||||
const SizedBox(height: 20),
|
||||
prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule != null) {
|
||||
_startCountdown(schedule);
|
||||
return _buildHeroCard(context, schedule);
|
||||
}
|
||||
return _buildHeroCardPlaceholder(context);
|
||||
},
|
||||
loading: () => _buildHeroCardPlaceholder(context),
|
||||
error: (_, __) => _buildHeroCardPlaceholder(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrayerTimesSection(context, prayerTimesAsync),
|
||||
const SizedBox(height: 24),
|
||||
_buildChecklistSummary(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
_buildWeeklyProgress(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, bool isDark) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.primary, width: 2),
|
||||
color: AppColors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
child: const Icon(Icons.person, size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Selamat datang,',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Assalamu'alaikum",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: Icon(
|
||||
Icons.settings_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
final name = _nextPrayerName.isNotEmpty
|
||||
? _nextPrayerName
|
||||
: (next?.name ?? 'Isya');
|
||||
final time = next?.time ?? '--:--';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -20,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.schedule,
|
||||
size: 16,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'SHOLAT BERIKUTNYA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$name — $time',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Hitung mundur: ${_formatCountdown(_countdown)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// City name
|
||||
Text(
|
||||
'📍 ${schedule.cityName}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.explore, size: 18, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Arah Kiblat',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.volume_up,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCardPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.onPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrayerTimesSection(
|
||||
BuildContext context, AsyncValue<DaySchedule?> prayerTimesAsync) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true
|
||||
? 'Jadwal Sholat Besok'
|
||||
: 'Jadwal Sholat Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true ? 'BESOK' : 'HARI INI',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule == null) return const SizedBox();
|
||||
final prayers = schedule.prayerList.where(
|
||||
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
|
||||
.contains(p.name),
|
||||
).toList();
|
||||
return ListView.separated(
|
||||
controller: _prayerScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: prayers.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final p = prayers[i];
|
||||
final icon = _prayerIcon(p.name);
|
||||
// Auto-scroll to active prayer on first build
|
||||
if (p.isActive && i > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_prayerScrollController.hasClients) {
|
||||
final targetOffset = i * 124.0; // 112 width + 12 gap
|
||||
_prayerScrollController.animateTo(
|
||||
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return PrayerTimeCard(
|
||||
prayerName: p.name,
|
||||
time: p.time,
|
||||
icon: icon,
|
||||
isActive: p.isActive,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
const Center(child: Text('Gagal memuat jadwal')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _prayerIcon(String name) {
|
||||
switch (name) {
|
||||
case 'Subuh':
|
||||
return Icons.wb_twilight;
|
||||
case 'Dzuhur':
|
||||
return Icons.wb_sunny;
|
||||
case 'Ashar':
|
||||
return Icons.filter_drama;
|
||||
case 'Maghrib':
|
||||
return Icons.wb_twilight;
|
||||
case 'Isya':
|
||||
return Icons.dark_mode;
|
||||
default:
|
||||
return Icons.schedule;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChecklistSummary(BuildContext context, bool isDark) {
|
||||
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = box.get(todayKey);
|
||||
|
||||
final points = log?.totalPoints ?? 0;
|
||||
// We can assume a max "excellent" day is around 150 points for the progress ring scale
|
||||
final percent = (points / 150).clamp(0.0, 1.0);
|
||||
|
||||
// Prepare dynamic preview lines
|
||||
int fardhuCompleted = 0;
|
||||
if (log != null) {
|
||||
fardhuCompleted = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
}
|
||||
|
||||
String amalanText = 'Belum ada data';
|
||||
if (log != null) {
|
||||
List<String> aList = [];
|
||||
if (log.tilawahLog?.isCompleted == true) aList.add('Tilawah');
|
||||
if (log.puasaLog?.completed == true) aList.add('Puasa');
|
||||
if (log.dzikirLog?.pagi == true) aList.add('Dzikir');
|
||||
if (aList.isNotEmpty) {
|
||||
amalanText = aList.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Poin Ibadah Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Kumpulkan poin dengan konsisten!',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: percent,
|
||||
strokeWidth: 4,
|
||||
backgroundColor:
|
||||
AppColors.primary.withValues(alpha: 0.15),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary),
|
||||
),
|
||||
Text(
|
||||
'$points',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
|
||||
const SizedBox(height: 8),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Amalan Selesai', amalanText, points > 50),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/checklist'),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Lihat Semua Checklist',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checklistPreviewItem(BuildContext context, bool isDark, String title,
|
||||
String subtitle, bool completed) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
completed ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: AppColors.primary,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyProgress(BuildContext context, bool isDark) {
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Reverse so today is on the far right (index 6)
|
||||
final last7Days = List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
|
||||
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
final weekPoints = <int>[];
|
||||
for (final d in last7Days) {
|
||||
final k = DateFormat('yyyy-MM-dd').format(d);
|
||||
final l = box.get(k);
|
||||
weekPoints.add(l?.totalPoints ?? 0);
|
||||
}
|
||||
|
||||
// Find the max points acquired this week to scale the bars, with a minimum floor of 50
|
||||
final maxPts = weekPoints.reduce((a, b) => a > b ? a : b).clamp(50, 300);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Progres Poin Mingguan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(7, (i) {
|
||||
final val = weekPoints[i];
|
||||
final ratio = (val / maxPts).clamp(0.1, 1.0);
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 80 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: val > 0
|
||||
? AppColors.primary.withValues(
|
||||
alpha: 0.2 + ratio * 0.8)
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
daysLabels[last7Days[i].weekday - 1], // Correct localized day
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == 6 ? FontWeight.w800 : FontWeight.w600,
|
||||
color: i == 6
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
Reference in New Issue
Block a user