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:
dwindown
2026-03-13 15:42:17 +07:00
commit faadc1865d
189 changed files with 23834 additions and 0 deletions

25
lib/app/app.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/providers/theme_provider.dart';
import 'router.dart';
import 'theme/app_theme.dart';
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeProvider);
return MaterialApp.router(
title: 'Jamshalat Diary',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeMode,
routerConfig: appRouter,
);
}
}

175
lib/app/router.dart Normal file
View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/widgets/bottom_nav_bar.dart';
import '../features/dashboard/presentation/dashboard_screen.dart';
import '../features/imsakiyah/presentation/imsakiyah_screen.dart';
import '../features/checklist/presentation/checklist_screen.dart';
import '../features/laporan/presentation/laporan_screen.dart';
import '../features/tools/presentation/tools_screen.dart';
import '../features/dzikir/presentation/dzikir_screen.dart';
import '../features/qibla/presentation/qibla_screen.dart';
import '../features/quran/presentation/quran_screen.dart';
import '../features/quran/presentation/quran_reading_screen.dart';
import '../features/quran/presentation/quran_murattal_screen.dart';
import '../features/quran/presentation/quran_bookmarks_screen.dart';
import '../features/settings/presentation/settings_screen.dart';
/// Navigation key for the shell navigator (bottom-nav screens).
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// GoRouter configuration per PRD §5.2.
final GoRouter appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/',
routes: [
// ── Shell route (bottom nav persists) ──
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => _ScaffoldWithNav(child: child),
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => const NoTransitionPage(
child: DashboardScreen(),
),
routes: [
GoRoute(
path: 'qibla',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QiblaScreen(),
),
],
),
GoRoute(
path: '/imsakiyah',
pageBuilder: (context, state) => const NoTransitionPage(
child: ImsakiyahScreen(),
),
),
GoRoute(
path: '/checklist',
pageBuilder: (context, state) => const NoTransitionPage(
child: ChecklistScreen(),
),
),
GoRoute(
path: '/laporan',
pageBuilder: (context, state) => const NoTransitionPage(
child: LaporanScreen(),
),
),
GoRoute(
path: '/tools',
pageBuilder: (context, state) => const NoTransitionPage(
child: ToolsScreen(),
),
routes: [
GoRoute(
path: 'dzikir',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const DzikirScreen(),
),
GoRoute(
path: 'quran',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QuranScreen(),
routes: [
GoRoute(
path: 'bookmarks',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QuranBookmarksScreen(),
),
GoRoute(
path: ':surahId',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
},
routes: [
GoRoute(
path: 'murattal',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final qariId = state.uri.queryParameters['qariId'];
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
return QuranMurattalScreen(
surahId: surahId,
initialQariId: qariId,
autoPlay: autoplay,
);
},
),
],
),
],
),
GoRoute(
path: 'qibla',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QiblaScreen(),
),
],
),
],
),
// ── Settings (pushed, no bottom nav) ──
GoRoute(
path: '/settings',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const SettingsScreen(),
),
],
);
/// Scaffold wrapper that provides the persistent bottom nav bar.
class _ScaffoldWithNav extends StatelessWidget {
const _ScaffoldWithNav({required this.child});
final Widget child;
/// Maps route locations to bottom nav indices.
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/imsakiyah')) return 1;
if (location.startsWith('/checklist')) return 2;
if (location.startsWith('/laporan')) return 3;
if (location.startsWith('/tools')) return 4;
return 0;
}
void _onTap(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/imsakiyah');
break;
case 2:
context.go('/checklist');
break;
case 3:
context.go('/laporan');
break;
case 4:
context.go('/tools');
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: AppBottomNavBar(
currentIndex: _currentIndex(context),
onTap: (i) => _onTap(context, i),
),
);
}
}

0
lib/app/theme/.gitkeep Normal file
View File

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
/// All color tokens from PRD §3.1 — light and dark values as static constants.
class AppColors {
AppColors._();
// ── Primary ──
static const Color primary = Color(0xFF70DF20);
static const Color onPrimary = Color(0xFF0A1A00);
// ── Background ──
static const Color backgroundLight = Color(0xFFF7F8F6);
static const Color backgroundDark = Color(0xFF182111);
// ── Surface ──
static const Color surfaceLight = Color(0xFFFFFFFF);
static const Color surfaceDark = Color(0xFF1E2A14);
// ── Sage (secondary text / section labels) ──
static const Color sage = Color(0xFF728764);
// ── Cream (dividers, borders — light mode only) ──
static const Color cream = Color(0xFFF2F4F0);
// ── Text ──
static const Color textPrimaryLight = Color(0xFF1A2A0A);
static const Color textPrimaryDark = Color(0xFFF2F4F0);
static const Color textSecondaryLight = Color(0xFF64748B);
static const Color textSecondaryDark = Color(0xFF94A3B8);
// ── Semantic ──
static const Color errorLight = Color(0xFFEF4444);
static const Color errorDark = Color(0xFFF87171);
static const Color successLight = Color(0xFF22C55E);
static const Color successDark = Color(0xFF4ADE80);
// ── Convenience helpers for theme building ──
static ColorScheme get lightColorScheme => ColorScheme.light(
primary: primary,
onPrimary: onPrimary,
surface: surfaceLight,
onSurface: textPrimaryLight,
error: errorLight,
onError: Colors.white,
secondary: sage,
onSecondary: Colors.white,
);
static ColorScheme get darkColorScheme => ColorScheme.dark(
primary: primary,
onPrimary: onPrimary,
surface: surfaceDark,
onSurface: textPrimaryDark,
error: errorDark,
onError: Colors.black,
secondary: sage,
onSecondary: Colors.white,
);
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// Typography definitions from PRD §3.2.
/// Plus Jakarta Sans (bundled) for UI text, Amiri (bundled) for Arabic content.
class AppTextStyles {
AppTextStyles._();
static const String _fontFamily = 'PlusJakartaSans';
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
static const TextTheme textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 32,
fontWeight: FontWeight.w800,
),
headlineMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w700,
),
titleLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w700,
),
titleMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
),
bodyMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
),
bodySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
),
labelSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
);
// ── Arabic text styles (Amiri — bundled font) ──
static const TextStyle arabicBody = TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
fontWeight: FontWeight.w400,
height: 2.0,
);
static const TextStyle arabicLarge = TextStyle(
fontFamily: 'Amiri',
fontSize: 28,
fontWeight: FontWeight.w700,
height: 2.2,
);
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_text_styles.dart';
/// ThemeData for light and dark modes, Material 3 enabled.
class AppTheme {
AppTheme._();
static ThemeData get light => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: AppColors.lightColorScheme,
scaffoldBackgroundColor: AppColors.backgroundLight,
textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.textPrimaryLight,
displayColor: AppColors.textPrimaryLight,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: AppColors.textPrimaryLight),
titleTextStyle: TextStyle(
color: AppColors.textPrimaryLight,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceLight,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondaryLight,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: AppColors.surfaceLight,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: AppColors.cream,
),
),
),
dividerColor: AppColors.cream,
);
static ThemeData get dark => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.backgroundDark,
textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.textPrimaryDark,
displayColor: AppColors.textPrimaryDark,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: AppColors.textPrimaryDark),
titleTextStyle: TextStyle(
color: AppColors.textPrimaryDark,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceDark,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondaryDark,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: AppColors.surfaceDark,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: AppColors.primary.withValues(alpha: 0.1),
),
),
),
dividerColor: AppColors.surfaceDark,
);
}

4
lib/app/theme/theme.dart Normal file
View File

@@ -0,0 +1,4 @@
// Barrel file for theme exports.
export 'app_colors.dart';
export 'app_text_styles.dart';
export 'app_theme.dart';

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
/// Theme mode state provider.
final themeProvider = StateProvider<ThemeMode>((ref) {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default');
return settings?.themeModeIndex == 1 ? ThemeMode.light : ThemeMode.dark;
});

View File

@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Models an active reading session for Tilawah
class TilawahSession {
final int startSurahId;
final String startSurahName;
final int startVerseId;
const TilawahSession({
required this.startSurahId,
required this.startSurahName,
required this.startVerseId,
});
TilawahSession copyWith({
int? startSurahId,
String? startSurahName,
int? startVerseId,
}) {
return TilawahSession(
startSurahId: startSurahId ?? this.startSurahId,
startSurahName: startSurahName ?? this.startSurahName,
startVerseId: startVerseId ?? this.startVerseId,
);
}
}
/// A state notifier to manage the global start state of a reading session.
/// If state is null, no active tracking is occurring.
class TilawahTrackingNotifier extends StateNotifier<TilawahSession?> {
TilawahTrackingNotifier() : super(null);
/// Start a new tracking session
void startTracking({
required int surahId,
required String surahName,
required int verseId
}) {
state = TilawahSession(
startSurahId: surahId,
startSurahName: surahName,
startVerseId: verseId,
);
}
/// Stop tracking (after recording)
void stopTracking() {
state = null;
}
}
final tilawahTrackingProvider = StateNotifierProvider<TilawahTrackingNotifier, TilawahSession?>((ref) {
return TilawahTrackingNotifier();
});

0
lib/core/utils/.gitkeep Normal file
View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// 5-tab bottom navigation bar per PRD §5.1.
/// Uses Material Symbols outlined (inactive) and filled (active).
class AppBottomNavBar extends StatelessWidget {
const AppBottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
final int currentIndex;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
activeIcon: Icon(Icons.calendar_today),
label: 'Jadwal',
),
BottomNavigationBarItem(
icon: Icon(Icons.rule_outlined),
activeIcon: Icon(Icons.rule),
label: 'Ibadah',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_outlined),
activeIcon: Icon(Icons.bar_chart),
label: 'Laporan',
),
BottomNavigationBarItem(
icon: Icon(Icons.auto_fix_high_outlined),
activeIcon: Icon(Icons.auto_fix_high),
label: 'Alat',
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Custom iOS-style toggle switch (51×31dp) per PRD §6.9.
/// Uses AnimatedContainer + GestureDetector for smooth animation.
class IosToggle extends StatelessWidget {
const IosToggle({
super.key,
required this.value,
required this.onChanged,
});
final bool value;
final ValueChanged<bool> onChanged;
static const double _width = 51.0;
static const double _height = 31.0;
static const double _thumbSize = 27.0;
static const double _thumbPadding = 2.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: _width,
height: _height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_height / 2),
color: value ? AppColors.primary : AppColors.cream,
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: _thumbSize,
height: _thumbSize,
margin: const EdgeInsets.all(_thumbPadding),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Reusable prayer time card widget for the horizontal scroll on Dashboard.
/// Will be fully implemented in Phase 3.
class PrayerTimeCard extends StatelessWidget {
const PrayerTimeCard({
super.key,
required this.prayerName,
required this.time,
required this.icon,
this.isActive = false,
});
final String prayerName;
final String time;
final IconData icon;
final bool isActive;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 112,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: isActive
? AppColors.primary.withValues(alpha: 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isActive
? AppColors.primary
: AppColors.primary.withValues(alpha: 0.1),
width: isActive ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 24,
color: isActive ? AppColors.primary : AppColors.sage,
),
const SizedBox(height: 8),
Text(
prayerName,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: isActive ? AppColors.primary : null,
),
),
const SizedBox(height: 4),
Text(
time,
style: theme.textTheme.bodySmall?.copyWith(
color: isActive
? AppColors.primary
: theme.textTheme.bodySmall?.color,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Reusable linear progress bar with primary fill.
/// Configurable height, borderRadius, and value (0.01.0).
class AppProgressBar extends StatelessWidget {
const AppProgressBar({
super.key,
required this.value,
this.height = 12.0,
this.borderRadius,
this.backgroundColor,
this.fillColor,
});
/// Progress value from 0.0 to 1.0.
final double value;
/// Height of the bar. Default 12dp.
final double height;
/// Border radius. Defaults to stadium (full).
final BorderRadius? borderRadius;
/// Background track color. Defaults to white/10 (dark) or primary/10 (light).
final Color? backgroundColor;
/// Fill color. Defaults to AppColors.primary.
final Color? fillColor;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final trackColor = backgroundColor ??
(isDark
? Colors.white.withValues(alpha: 0.1)
: AppColors.primary.withValues(alpha: 0.1));
final fill = fillColor ?? AppColors.primary;
final radius = borderRadius ?? BorderRadius.circular(height / 2);
return ClipRRect(
borderRadius: radius,
child: SizedBox(
height: height,
child: Stack(
children: [
// Track
Container(
decoration: BoxDecoration(
color: trackColor,
borderRadius: radius,
),
),
// Fill
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: fill,
borderRadius: radius,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Reusable uppercase section label (e.g. "NOTIFICATIONS", "DISPLAY").
/// Uses sage color, tracking-wider, bold weight per PRD §3.2 labelSmall.
class SectionHeader extends StatelessWidget {
const SectionHeader({
super.key,
required this.title,
this.trailing,
});
final String title;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppColors.sage,
letterSpacing: 1.5,
),
),
if (trailing != null) trailing!,
],
),
);
}
}

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,119 @@
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'models/app_settings.dart';
import 'models/checklist_item.dart';
import 'models/daily_worship_log.dart';
import 'models/dzikir_counter.dart';
import 'models/quran_bookmark.dart';
import 'models/cached_prayer_times.dart';
import 'models/shalat_log.dart';
import 'models/tilawah_log.dart';
import 'models/dzikir_log.dart';
import 'models/puasa_log.dart';
/// Box name constants for Hive.
class HiveBoxes {
HiveBoxes._();
static const String settings = 'settings';
static const String checklistItems = 'checklist_items';
static const String worshipLogs = 'worship_logs';
static const String dzikirCounters = 'dzikir_counters';
static const String bookmarks = 'bookmarks';
static const String cachedPrayerTimes = 'cached_prayer_times';
}
/// Initialize Hive and open all boxes.
Future<void> initHive() async {
await Hive.initFlutter();
// Register adapters
Hive.registerAdapter(AppSettingsAdapter());
Hive.registerAdapter(ChecklistItemAdapter());
Hive.registerAdapter(DailyWorshipLogAdapter());
Hive.registerAdapter(DzikirCounterAdapter());
Hive.registerAdapter(QuranBookmarkAdapter());
Hive.registerAdapter(CachedPrayerTimesAdapter());
Hive.registerAdapter(ShalatLogAdapter());
Hive.registerAdapter(TilawahLogAdapter());
Hive.registerAdapter(DzikirLogAdapter());
Hive.registerAdapter(PuasaLogAdapter());
// Open boxes
try {
await Hive.openBox<AppSettings>(HiveBoxes.settings);
} catch (e) {
debugPrint('Settings box corrupted, resetting: $e');
if (Hive.isBoxOpen(HiveBoxes.settings)) {
await Hive.box<AppSettings>(HiveBoxes.settings).close();
}
await Hive.deleteBoxFromDisk(HiveBoxes.settings);
await Hive.openBox<AppSettings>(HiveBoxes.settings);
}
await Hive.openBox<ChecklistItem>(HiveBoxes.checklistItems);
final worshipBox = await Hive.openBox<DailyWorshipLog>(HiveBoxes.worshipLogs);
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
final keysToDelete = [];
for (final key in worshipBox.keys) {
try {
final log = worshipBox.get(key);
if (log != null) {
log.shalatLogs.values.toList(); // Force evaluation
}
} catch (_) {
keysToDelete.add(key);
}
}
if (keysToDelete.isNotEmpty) {
await worshipBox.deleteAll(keysToDelete);
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
}
}
/// Seeds default settings and checklist items on first launch.
Future<void> seedDefaults() async {
// Seed AppSettings
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
if (settingsBox.isEmpty) {
await settingsBox.put('default', AppSettings());
}
// Seed default checklist items
final checklistBox = Hive.box<ChecklistItem>(HiveBoxes.checklistItems);
if (checklistBox.isEmpty) {
final defaults = [
ChecklistItem(
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
ChecklistItem(
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
ChecklistItem(
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
ChecklistItem(
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
ChecklistItem(
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
ChecklistItem(
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
subtitle: '1 Juz', sortOrder: 5),
ChecklistItem(
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
subtitle: '1 session', sortOrder: 6),
ChecklistItem(
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
subtitle: '1 session', sortOrder: 7),
ChecklistItem(
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
ChecklistItem(
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
];
for (final item in defaults) {
await checklistBox.put(item.id, item);
}
}
}

View File

@@ -0,0 +1,101 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'app_settings.g.dart';
/// User settings stored in Hive.
@HiveType(typeId: 0)
class AppSettings extends HiveObject {
@HiveField(0)
String userName;
@HiveField(1)
String userEmail;
@HiveField(2)
int themeModeIndex; // 0=system, 1=light, 2=dark
@HiveField(3)
double arabicFontSize;
@HiveField(4)
String uiLanguage; // 'id' or 'en'
@HiveField(5)
Map<String, bool> adhanEnabled;
@HiveField(6)
Map<String, int> iqamahOffset; // minutes
@HiveField(7)
String? checklistReminderTime; // HH:mm format
@HiveField(8)
double? lastLat;
@HiveField(9)
double? lastLng;
@HiveField(10)
String? lastCityName;
@HiveField(11)
int rawatibLevel; // 0 = Off, 1 = Muakkad Only, 2 = Full
@HiveField(12)
int tilawahTargetValue;
@HiveField(13)
String tilawahTargetUnit; // 'Juz', 'Halaman', 'Ayat'
@HiveField(14)
bool tilawahAutoSync;
@HiveField(15)
bool trackDzikir;
@HiveField(16)
bool trackPuasa;
@HiveField(17)
bool showLatin;
@HiveField(18)
bool showTerjemahan;
AppSettings({
this.userName = 'User',
this.userEmail = '',
this.themeModeIndex = 0,
this.arabicFontSize = 24.0,
this.uiLanguage = 'id',
Map<String, bool>? adhanEnabled,
Map<String, int>? iqamahOffset,
this.checklistReminderTime = '09:00',
this.lastLat,
this.lastLng,
this.lastCityName,
this.rawatibLevel = 1, // Default to Muakkad
this.tilawahTargetValue = 1,
this.tilawahTargetUnit = 'Juz',
this.tilawahAutoSync = false,
this.trackDzikir = true,
this.trackPuasa = false,
this.showLatin = true,
this.showTerjemahan = true,
}) : adhanEnabled = adhanEnabled ??
{
'fajr': true,
'dhuhr': true,
'asr': true,
'maghrib': true,
'isha': true,
},
iqamahOffset = iqamahOffset ??
{
'fajr': 15,
'dhuhr': 10,
'asr': 10,
'maghrib': 5,
'isha': 10,
};
}

View File

@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override
final int typeId = 0;
@override
AppSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AppSettings(
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
arabicFontSize: fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
showTerjemahan: fields.containsKey(18) ? fields[18] as bool? ?? true : true,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(19)
..writeByte(0)
..write(obj.userName)
..writeByte(1)
..write(obj.userEmail)
..writeByte(2)
..write(obj.themeModeIndex)
..writeByte(3)
..write(obj.arabicFontSize)
..writeByte(4)
..write(obj.uiLanguage)
..writeByte(5)
..write(obj.adhanEnabled)
..writeByte(6)
..write(obj.iqamahOffset)
..writeByte(7)
..write(obj.checklistReminderTime)
..writeByte(8)
..write(obj.lastLat)
..writeByte(9)
..write(obj.lastLng)
..writeByte(10)
..write(obj.lastCityName)
..writeByte(11)
..write(obj.rawatibLevel)
..writeByte(12)
..write(obj.tilawahTargetValue)
..writeByte(13)
..write(obj.tilawahTargetUnit)
..writeByte(14)
..write(obj.tilawahAutoSync)
..writeByte(15)
..write(obj.trackDzikir)
..writeByte(16)
..write(obj.trackPuasa)
..writeByte(17)
..write(obj.showLatin)
..writeByte(18)
..write(obj.showTerjemahan);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppSettingsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,50 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'cached_prayer_times.g.dart';
/// Cached prayer times for a specific location + date.
@HiveType(typeId: 5)
class CachedPrayerTimes extends HiveObject {
@HiveField(0)
String key; // 'lat_lng_yyyy-MM-dd'
@HiveField(1)
double lat;
@HiveField(2)
double lng;
@HiveField(3)
String date;
@HiveField(4)
DateTime fajr;
@HiveField(5)
DateTime sunrise;
@HiveField(6)
DateTime dhuhr;
@HiveField(7)
DateTime asr;
@HiveField(8)
DateTime maghrib;
@HiveField(9)
DateTime isha;
CachedPrayerTimes({
required this.key,
required this.lat,
required this.lng,
required this.date,
required this.fajr,
required this.sunrise,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
});
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cached_prayer_times.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CachedPrayerTimesAdapter extends TypeAdapter<CachedPrayerTimes> {
@override
final int typeId = 5;
@override
CachedPrayerTimes read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CachedPrayerTimes(
key: fields[0] as String,
lat: fields[1] as double,
lng: fields[2] as double,
date: fields[3] as String,
fajr: fields[4] as DateTime,
sunrise: fields[5] as DateTime,
dhuhr: fields[6] as DateTime,
asr: fields[7] as DateTime,
maghrib: fields[8] as DateTime,
isha: fields[9] as DateTime,
);
}
@override
void write(BinaryWriter writer, CachedPrayerTimes obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.key)
..writeByte(1)
..write(obj.lat)
..writeByte(2)
..write(obj.lng)
..writeByte(3)
..write(obj.date)
..writeByte(4)
..write(obj.fajr)
..writeByte(5)
..write(obj.sunrise)
..writeByte(6)
..write(obj.dhuhr)
..writeByte(7)
..write(obj.asr)
..writeByte(8)
..write(obj.maghrib)
..writeByte(9)
..write(obj.isha);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CachedPrayerTimesAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,34 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'checklist_item.g.dart';
/// A single checklist item definition (template, not daily state).
@HiveType(typeId: 1)
class ChecklistItem extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
String title;
@HiveField(2)
String category;
@HiveField(3)
String? subtitle;
@HiveField(4)
int sortOrder;
@HiveField(5)
bool isCustom;
ChecklistItem({
required this.id,
required this.title,
required this.category,
this.subtitle,
required this.sortOrder,
this.isCustom = false,
});
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'checklist_item.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ChecklistItemAdapter extends TypeAdapter<ChecklistItem> {
@override
final int typeId = 1;
@override
ChecklistItem read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ChecklistItem(
id: fields[0] as String,
title: fields[1] as String,
category: fields[2] as String,
subtitle: fields[3] as String?,
sortOrder: fields[4] as int,
isCustom: fields[5] as bool,
);
}
@override
void write(BinaryWriter writer, ChecklistItem obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.title)
..writeByte(2)
..write(obj.category)
..writeByte(3)
..write(obj.subtitle)
..writeByte(4)
..write(obj.sortOrder)
..writeByte(5)
..write(obj.isCustom);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChecklistItemAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,86 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'shalat_log.dart';
import 'tilawah_log.dart';
import 'dzikir_log.dart';
import 'puasa_log.dart';
part 'daily_worship_log.g.dart';
/// Daily worship completion log, keyed by date string 'yyyy-MM-dd'.
@HiveType(typeId: 2)
class DailyWorshipLog extends HiveObject {
@HiveField(0)
String date;
@HiveField(1)
Map<String, ShalatLog> shalatLogs; // e.g., 'subuh' -> ShalatLog
@HiveField(5)
TilawahLog? tilawahLog;
@HiveField(6)
DzikirLog? dzikirLog;
@HiveField(7)
PuasaLog? puasaLog;
@HiveField(2)
int totalItems;
@HiveField(3)
int completedCount;
@HiveField(4)
double completionPercent;
DailyWorshipLog({
required this.date,
Map<String, ShalatLog>? shalatLogs,
this.tilawahLog,
this.dzikirLog,
this.puasaLog,
this.totalItems = 0,
this.completedCount = 0,
this.completionPercent = 0.0,
}) : shalatLogs = shalatLogs ?? {};
/// Dynamically calculates the "Poin Ibadah" for this day.
int get totalPoints {
int points = 0;
// 1. Shalat Fardhu
for (final sLog in shalatLogs.values) {
if (sLog.completed) {
if (sLog.location == 'Masjid') {
points += 25;
} else {
points += 10;
}
}
if (sLog.qabliyah == true) points += 5;
if (sLog.badiyah == true) points += 5;
}
// 2. Tilawah
if (tilawahLog != null) {
// 1 point per Ayat read
points += tilawahLog!.rawAyatRead;
// Bonus 20 points for completing daily target
if (tilawahLog!.isCompleted) {
points += 20;
}
}
// 3. Dzikir & Puasa
if (dzikirLog != null) {
if (dzikirLog!.pagi) points += 10;
if (dzikirLog!.petang) points += 10;
}
if (puasaLog != null && puasaLog!.completed) {
points += 30;
}
return points;
}
}

View File

@@ -0,0 +1,70 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'daily_worship_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DailyWorshipLogAdapter extends TypeAdapter<DailyWorshipLog> {
@override
final int typeId = 2;
@override
DailyWorshipLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
Map<String, ShalatLog>? parsedShalatLogs;
try {
parsedShalatLogs = (fields[1] as Map?)?.cast<String, ShalatLog>();
} catch (_) {
// If casting fails (e.g. it was the old Map<String, bool>), ignore it.
parsedShalatLogs = {};
}
return DailyWorshipLog(
date: fields[0] as String,
shalatLogs: parsedShalatLogs,
tilawahLog: fields[5] as TilawahLog?,
dzikirLog: fields[6] as DzikirLog?,
puasaLog: fields[7] as PuasaLog?,
totalItems: fields[2] as int,
completedCount: fields[3] as int,
completionPercent: fields[4] as double,
);
}
@override
void write(BinaryWriter writer, DailyWorshipLog obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.date)
..writeByte(1)
..write(obj.shalatLogs)
..writeByte(5)
..write(obj.tilawahLog)
..writeByte(6)
..write(obj.dzikirLog)
..writeByte(7)
..write(obj.puasaLog)
..writeByte(2)
..write(obj.totalItems)
..writeByte(3)
..write(obj.completedCount)
..writeByte(4)
..write(obj.completionPercent);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DailyWorshipLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,26 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'dzikir_counter.g.dart';
/// Counter for a single dzikir item on a specific date.
@HiveType(typeId: 3)
class DzikirCounter extends HiveObject {
@HiveField(0)
String dzikirId;
@HiveField(1)
String date;
@HiveField(2)
int count;
@HiveField(3)
int target;
DzikirCounter({
required this.dzikirId,
required this.date,
this.count = 0,
required this.target,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dzikir_counter.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DzikirCounterAdapter extends TypeAdapter<DzikirCounter> {
@override
final int typeId = 3;
@override
DzikirCounter read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return DzikirCounter(
dzikirId: fields[0] as String,
date: fields[1] as String,
count: fields[2] as int,
target: fields[3] as int,
);
}
@override
void write(BinaryWriter writer, DzikirCounter obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.dzikirId)
..writeByte(1)
..write(obj.date)
..writeByte(2)
..write(obj.count)
..writeByte(3)
..write(obj.target);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DzikirCounterAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,17 @@
import 'package:hive/hive.dart';
part 'dzikir_log.g.dart';
@HiveType(typeId: 9)
class DzikirLog {
@HiveField(0)
bool pagi;
@HiveField(1)
bool petang;
DzikirLog({
this.pagi = false,
this.petang = false,
});
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dzikir_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DzikirLogAdapter extends TypeAdapter<DzikirLog> {
@override
final int typeId = 9;
@override
DzikirLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return DzikirLog(
pagi: fields[0] as bool,
petang: fields[1] as bool,
);
}
@override
void write(BinaryWriter writer, DzikirLog obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.pagi)
..writeByte(1)
..write(obj.petang);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DzikirLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,17 @@
import 'package:hive/hive.dart';
part 'puasa_log.g.dart';
@HiveType(typeId: 10)
class PuasaLog {
@HiveField(0)
String? jenisPuasa; // 'Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', etc.
@HiveField(1)
bool completed;
PuasaLog({
this.jenisPuasa,
this.completed = false,
});
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'puasa_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PuasaLogAdapter extends TypeAdapter<PuasaLog> {
@override
final int typeId = 10;
@override
PuasaLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PuasaLog(
jenisPuasa: fields[0] as String?,
completed: fields[1] as bool,
);
}
@override
void write(BinaryWriter writer, PuasaLog obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.jenisPuasa)
..writeByte(1)
..write(obj.completed);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PuasaLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,42 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'quran_bookmark.g.dart';
/// A bookmarked Quran verse.
@HiveType(typeId: 4)
class QuranBookmark extends HiveObject {
@HiveField(0)
int surahId;
@HiveField(1)
int verseId;
@HiveField(2)
String surahName;
@HiveField(3)
String verseText; // Arabic snippet
@HiveField(4)
DateTime savedAt;
@HiveField(5)
bool isLastRead;
@HiveField(6)
String? verseLatin;
@HiveField(7)
String? verseTranslation;
QuranBookmark({
required this.surahId,
required this.verseId,
required this.surahName,
required this.verseText,
required this.savedAt,
this.isLastRead = false,
this.verseLatin,
this.verseTranslation,
});
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'quran_bookmark.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class QuranBookmarkAdapter extends TypeAdapter<QuranBookmark> {
@override
final int typeId = 4;
@override
QuranBookmark read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return QuranBookmark(
surahId: fields[0] as int,
verseId: fields[1] as int,
surahName: fields[2] as String,
verseText: fields[3] as String,
savedAt: fields[4] as DateTime,
isLastRead: fields[5] as bool? ?? false,
verseLatin: fields[6] as String?,
verseTranslation: fields[7] as String?,
);
}
@override
void write(BinaryWriter writer, QuranBookmark obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.surahId)
..writeByte(1)
..write(obj.verseId)
..writeByte(2)
..write(obj.surahName)
..writeByte(3)
..write(obj.verseText)
..writeByte(4)
..write(obj.savedAt)
..writeByte(5)
..write(obj.isLastRead)
..writeByte(6)
..write(obj.verseLatin)
..writeByte(7)
..write(obj.verseTranslation);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is QuranBookmarkAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,25 @@
import 'package:hive/hive.dart';
part 'shalat_log.g.dart';
@HiveType(typeId: 7)
class ShalatLog {
@HiveField(0)
bool completed;
@HiveField(1)
String? location; // 'Rumah' or 'Masjid'
@HiveField(2)
bool? qabliyah;
@HiveField(3)
bool? badiyah;
ShalatLog({
this.completed = false,
this.location,
this.qabliyah,
this.badiyah,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'shalat_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ShalatLogAdapter extends TypeAdapter<ShalatLog> {
@override
final int typeId = 7;
@override
ShalatLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ShalatLog(
completed: fields[0] as bool,
location: fields[1] as String?,
qabliyah: fields[2] as bool?,
badiyah: fields[3] as bool?,
);
}
@override
void write(BinaryWriter writer, ShalatLog obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.completed)
..writeByte(1)
..write(obj.location)
..writeByte(2)
..write(obj.qabliyah)
..writeByte(3)
..write(obj.badiyah);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ShalatLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,35 @@
import 'package:hive/hive.dart';
part 'tilawah_log.g.dart';
@HiveType(typeId: 8)
class TilawahLog {
@HiveField(0)
int targetValue;
@HiveField(1)
String targetUnit; // 'Juz', 'Halaman', 'Ayat'
@HiveField(2)
int currentProgress;
@HiveField(3)
bool autoSync;
@HiveField(4)
int rawAyatRead;
@HiveField(5)
bool targetCompleted;
TilawahLog({
this.targetValue = 1,
this.targetUnit = 'Juz',
this.currentProgress = 0,
this.autoSync = false,
this.rawAyatRead = 0,
this.targetCompleted = false,
});
bool get isCompleted => targetCompleted;
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tilawah_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class TilawahLogAdapter extends TypeAdapter<TilawahLog> {
@override
final int typeId = 8;
@override
TilawahLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return TilawahLog(
targetValue: fields[0] as int,
targetUnit: fields[1] as String,
currentProgress: fields[2] as int,
autoSync: fields[3] as bool,
rawAyatRead: fields[4] as int? ?? 0,
targetCompleted: fields[5] as bool? ?? false,
);
}
@override
void write(BinaryWriter writer, TilawahLog obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.targetValue)
..writeByte(1)
..write(obj.targetUnit)
..writeByte(2)
..write(obj.currentProgress)
..writeByte(3)
..write(obj.autoSync)
..writeByte(4)
..write(obj.rawAyatRead)
..writeByte(5)
..write(obj.targetCompleted);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TilawahLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

View File

@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/dzikir_counter.dart';
import 'package:intl/intl.dart';
/// Represents a single dzikir item from the bundled JSON.
class DzikirItem {
final String id;
final String arabic;
final String transliteration;
final String translation;
final int targetCount;
final String? source;
DzikirItem({
required this.id,
required this.arabic,
required this.transliteration,
required this.translation,
required this.targetCount,
this.source,
});
factory DzikirItem.fromJson(Map<String, dynamic> json) {
return DzikirItem(
id: json['id'] as String,
arabic: json['arabic'] as String? ?? '',
transliteration: json['transliteration'] as String? ?? '',
translation: json['translation'] as String? ?? '',
targetCount: json['target_count'] as int? ?? 1,
source: json['source'] as String?,
);
}
}
/// Types of dzikir sessions.
enum DzikirType { pagi, petang }
/// Service to load dzikir data and manage counters.
class DzikirService {
DzikirService._();
static final DzikirService instance = DzikirService._();
final Map<DzikirType, List<DzikirItem>> _cache = {};
/// Load dzikir items from bundled JSON.
Future<List<DzikirItem>> getDzikir(DzikirType type) async {
if (_cache.containsKey(type)) return _cache[type]!;
final path = type == DzikirType.pagi
? 'assets/dzikir/dzikir_pagi.json'
: 'assets/dzikir/dzikir_petang.json';
try {
final jsonString = await rootBundle.loadString(path);
final List<dynamic> data = json.decode(jsonString);
_cache[type] =
data.map((d) => DzikirItem.fromJson(d as Map<String, dynamic>)).toList();
} catch (_) {
_cache[type] = [];
}
return _cache[type]!;
}
/// Get counters for a specific date from Hive.
Map<String, int> getCountersForDate(String date) {
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
final result = <String, int>{};
for (final key in box.keys) {
final counter = box.get(key);
if (counter != null && counter.date == date) {
result[counter.dzikirId] = counter.count;
}
}
return result;
}
/// Increment a dzikir counter for a specific ID on a specific date.
Future<void> increment(String dzikirId, String date, int target) async {
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
final key = '${dzikirId}_$date';
final existing = box.get(key);
if (existing != null) {
existing.count = (existing.count + 1).clamp(0, target);
await existing.save();
} else {
await box.put(
key,
DzikirCounter(
dzikirId: dzikirId,
date: date,
count: 1,
target: target,
),
);
}
}
/// Get today's date string.
String get todayKey => DateFormat('yyyy-MM-dd').format(DateTime.now());
}

View File

@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for EQuran.id v2 API.
/// Provides complete Quran data: Arabic, Indonesian translation,
/// tafsir, and audio from 6 qari.
class EQuranService {
static const String _baseUrl = 'https://equran.id/api/v2';
static final EQuranService instance = EQuranService._();
EQuranService._();
// In-memory cache
List<Map<String, dynamic>>? _surahListCache;
/// Get list of all 114 surahs.
Future<List<Map<String, dynamic>>> getAllSurahs() async {
if (_surahListCache != null) return _surahListCache!;
try {
final response = await http.get(Uri.parse('$_baseUrl/surat'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
_surahListCache =
List<Map<String, dynamic>>.from(data['data']);
return _surahListCache!;
}
}
} catch (e) {
// silent fallback
}
return [];
}
/// Get full surah with all ayat, audio, etc.
/// Returns the full surah data object.
Future<Map<String, dynamic>?> getSurah(int number) async {
try {
final response =
await http.get(Uri.parse('$_baseUrl/surat/$number'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
return Map<String, dynamic>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get tafsir for a surah.
Future<Map<String, dynamic>?> getTafsir(int number) async {
try {
final response =
await http.get(Uri.parse('$_baseUrl/tafsir/$number'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
return Map<String, dynamic>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get deterministic daily ayat from API
Future<Map<String, dynamic>?> getDailyAyat() async {
try {
final now = DateTime.now();
final dayOfYear = int.parse(now.difference(DateTime(now.year, 1, 1)).inDays.toString());
// Pick surah 1-114
int surahId = (dayOfYear % 114) + 1;
final surahData = await getSurah(surahId);
if (surahData != null && surahData['ayat'] != null) {
int totalAyat = surahData['jumlahAyat'] ?? 1;
int ayatIndex = dayOfYear % totalAyat;
final targetAyat = surahData['ayat'][ayatIndex];
return {
'surahName': surahData['namaLatin'],
'nomorSurah': surahId,
'nomorAyat': targetAyat['nomorAyat'],
'teksArab': targetAyat['teksArab'],
'teksIndonesia': targetAyat['teksIndonesia'],
};
}
} catch (e) {
// silent fallback
}
return null;
}
/// Available qari names mapped to audio key index.
static const Map<String, String> qariNames = {
'01': 'Abdullah Al-Juhany',
'02': 'Abdul Muhsin Al-Qasim',
'03': 'Abdurrahman As-Sudais',
'04': 'Ibrahim Al-Dossari',
'05': 'Misyari Rasyid Al-Afasi',
'06': 'Yasser Al-Dosari',
};
}

View File

@@ -0,0 +1,86 @@
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart' as geocoding;
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
/// Location service with GPS + fallback to last known location.
class LocationService {
LocationService._();
static final LocationService instance = LocationService._();
/// Request permission and get current GPS location.
Future<Position?> getCurrentLocation() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return null;
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) return null;
}
if (permission == LocationPermission.deniedForever) return null;
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 10),
),
);
// Save to settings for fallback
await _saveLastKnown(position.latitude, position.longitude);
return position;
} catch (_) {
return null;
}
}
/// Get last known location from Hive settings.
({double lat, double lng, String? cityName})? getLastKnownLocation() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings?.lastLat != null && settings?.lastLng != null) {
return (
lat: settings!.lastLat!,
lng: settings.lastLng!,
cityName: settings.lastCityName,
);
}
return null;
}
/// Reverse geocode to get city name from coordinates.
Future<String> getCityName(double lat, double lng) async {
try {
final placemarks = await geocoding.placemarkFromCoordinates(lat, lng);
if (placemarks.isNotEmpty) {
final place = placemarks.first;
final city = place.locality ?? place.subAdministrativeArea ?? 'Unknown';
final country = place.country ?? '';
return '$city, $country';
}
} catch (_) {
// Geocoding may fail offline — return coords
}
return '${lat.toStringAsFixed(2)}, ${lng.toStringAsFixed(2)}';
}
/// Save last known position to Hive.
Future<void> _saveLastKnown(double lat, double lng) async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings != null) {
settings.lastLat = lat;
settings.lastLng = lng;
try {
settings.lastCityName = await getCityName(lat, lng);
} catch (_) {
// Ignore geocoding errors
}
await settings.save();
}
}
}

View File

@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for myQuran.com v3 Sholat API.
/// Provides Kemenag-accurate prayer times for Indonesian cities.
class MyQuranSholatService {
static const String _baseUrl = 'https://api.myquran.com/v3/sholat';
static final MyQuranSholatService instance = MyQuranSholatService._();
MyQuranSholatService._();
/// Search for a city/kabupaten by name.
/// Returns list of {id, lokasi}.
Future<List<Map<String, dynamic>>> searchCity(String query) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/kota/cari/$query'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
return List<Map<String, dynamic>>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return [];
}
/// Get prayer times for a specific city and date.
/// [cityId] = myQuran city ID (hash string)
/// [date] = 'yyyy-MM-dd' format
/// Returns map: {tanggal, imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
Future<Map<String, String>?> getDailySchedule(
String cityId, String date) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$date'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
final jadwal = data['data']['jadwal'][date];
if (jadwal != null) {
return Map<String, String>.from(
jadwal.map((k, v) => MapEntry(k.toString(), v.toString())),
);
}
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get monthly prayer schedule.
/// [month] = 'yyyy-MM' format
/// Returns map of date → jadwal.
Future<Map<String, Map<String, String>>> getMonthlySchedule(
String cityId, String month) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$month'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
final jadwalMap = data['data']['jadwal'] as Map<String, dynamic>;
final result = <String, Map<String, String>>{};
for (final entry in jadwalMap.entries) {
result[entry.key] = Map<String, String>.from(
(entry.value as Map).map(
(k, v) => MapEntry(k.toString(), v.toString())),
);
}
return result;
}
}
} catch (e) {
// silent fallback
}
return {};
}
/// Get city info (kabko, prov) from a jadwal response.
Future<Map<String, String>?> getCityInfo(String cityId) async {
final today =
DateTime.now().toIso8601String().substring(0, 10);
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$today'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
return {
'kabko': data['data']['kabko']?.toString() ?? '',
'prov': data['data']['prov']?.toString() ?? '',
};
}
}
} catch (e) {
// silent fallback
}
return null;
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
/// Notification service for Adhan and Iqamah notifications.
class NotificationService {
NotificationService._();
static final NotificationService instance = NotificationService._();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
/// Initialize notification channels.
Future<void> init() async {
if (_initialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const settings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
);
await _plugin.initialize(settings);
_initialized = true;
}
/// Schedule an Adhan notification at a specific time.
Future<void> scheduleAdhan({
required int id,
required String prayerName,
required DateTime time,
}) async {
await _plugin.zonedSchedule(
id,
'Adhan - $prayerName',
'It\'s time for $prayerName prayer',
tz.TZDateTime.from(time, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'adhan_channel',
'Adhan Notifications',
channelDescription: 'Prayer time adhan notifications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}
/// Schedule an Iqamah reminder notification.
Future<void> scheduleIqamah({
required int id,
required String prayerName,
required DateTime adhanTime,
required int offsetMinutes,
}) async {
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
await _plugin.zonedSchedule(
id + 100, // Offset IDs for iqamah
'Iqamah - $prayerName',
'Iqamah for $prayerName in $offsetMinutes minutes',
tz.TZDateTime.from(iqamahTime, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'iqamah_channel',
'Iqamah Reminders',
channelDescription: 'Iqamah reminder notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}
/// Cancel all pending notifications.
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,126 @@
import 'package:adhan/adhan.dart' as adhan;
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/cached_prayer_times.dart';
import 'package:intl/intl.dart';
/// Result object for prayer times.
class PrayerTimesResult {
final DateTime fajr;
final DateTime sunrise;
final DateTime dhuhr;
final DateTime asr;
final DateTime maghrib;
final DateTime isha;
PrayerTimesResult({
required this.fajr,
required this.sunrise,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
});
}
/// Prayer time calculation service using the adhan package.
class PrayerService {
PrayerService._();
static final PrayerService instance = PrayerService._();
/// Calculate prayer times for a given location and date.
/// Uses cache if available; writes to cache after calculation.
PrayerTimesResult getPrayerTimes(double lat, double lng, DateTime date) {
final dateKey = DateFormat('yyyy-MM-dd').format(date);
final cacheKey = '${lat.toStringAsFixed(4)}_${lng.toStringAsFixed(4)}_$dateKey';
// Check cache
final cacheBox = Hive.box<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
final cached = cacheBox.get(cacheKey);
if (cached != null) {
return PrayerTimesResult(
fajr: cached.fajr,
sunrise: cached.sunrise,
dhuhr: cached.dhuhr,
asr: cached.asr,
maghrib: cached.maghrib,
isha: cached.isha,
);
}
// Calculate using adhan package
final coordinates = adhan.Coordinates(lat, lng);
final dateComponents = adhan.DateComponents(date.year, date.month, date.day);
final params = adhan.CalculationMethod.muslim_world_league.getParameters();
params.madhab = adhan.Madhab.shafi;
final prayerTimes = adhan.PrayerTimes(coordinates, dateComponents, params);
final result = PrayerTimesResult(
fajr: prayerTimes.fajr!,
sunrise: prayerTimes.sunrise!,
dhuhr: prayerTimes.dhuhr!,
asr: prayerTimes.asr!,
maghrib: prayerTimes.maghrib!,
isha: prayerTimes.isha!,
);
// Cache result
cacheBox.put(
cacheKey,
CachedPrayerTimes(
key: cacheKey,
lat: lat,
lng: lng,
date: dateKey,
fajr: result.fajr,
sunrise: result.sunrise,
dhuhr: result.dhuhr,
asr: result.asr,
maghrib: result.maghrib,
isha: result.isha,
),
);
return result;
}
/// Get the next prayer name and time from now.
MapEntry<String, DateTime>? getNextPrayer(PrayerTimesResult times) {
final now = DateTime.now();
final entries = {
'Fajr': times.fajr,
'Dhuhr': times.dhuhr,
'Asr': times.asr,
'Maghrib': times.maghrib,
'Isha': times.isha,
};
for (final entry in entries.entries) {
if (entry.value.isAfter(now)) {
return entry;
}
}
return null; // All prayers passed for today
}
/// Get the current active prayer (the last prayer whose time has passed).
String? getCurrentPrayer(PrayerTimesResult times) {
final now = DateTime.now();
String? current;
if (now.isAfter(times.isha)) {
current = 'Isha';
} else if (now.isAfter(times.maghrib)) {
current = 'Maghrib';
} else if (now.isAfter(times.asr)) {
current = 'Asr';
} else if (now.isAfter(times.dhuhr)) {
current = 'Dhuhr';
} else if (now.isAfter(times.fajr)) {
current = 'Fajr';
}
return current;
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:flutter/services.dart';
/// Represents a single Surah with its verses.
class Surah {
final int id;
final String nameArabic;
final String nameLatin;
final int verseCount;
final int juzStart;
final String revelationType;
final List<Verse> verses;
Surah({
required this.id,
required this.nameArabic,
required this.nameLatin,
required this.verseCount,
this.juzStart = 1,
this.revelationType = 'Meccan',
this.verses = const [],
});
factory Surah.fromJson(Map<String, dynamic> json) {
return Surah(
id: json['id'] as int,
nameArabic: json['name_arabic'] as String? ?? '',
nameLatin: json['name_latin'] as String? ?? '',
verseCount: json['verse_count'] as int? ?? 0,
juzStart: json['juz_start'] as int? ?? 1,
revelationType: json['revelation_type'] as String? ?? 'Meccan',
verses: (json['verses'] as List<dynamic>?)
?.map((v) => Verse.fromJson(v as Map<String, dynamic>))
.toList() ??
[],
);
}
}
/// A single Quran verse.
class Verse {
final int id;
final String arabic;
final String? transliteration;
final String translationId;
Verse({
required this.id,
required this.arabic,
this.transliteration,
required this.translationId,
});
factory Verse.fromJson(Map<String, dynamic> json) {
return Verse(
id: json['id'] as int,
arabic: json['arabic'] as String? ?? '',
transliteration: json['transliteration'] as String?,
translationId: json['translation_id'] as String? ?? '',
);
}
}
/// Service to load Quran data from bundled JSON asset.
class QuranService {
QuranService._();
static final QuranService instance = QuranService._();
List<Surah>? _cachedSurahs;
/// Load all 114 Surahs from local JSON. Cached in memory after first load.
Future<List<Surah>> getAllSurahs() async {
if (_cachedSurahs != null) return _cachedSurahs!;
try {
final jsonString =
await rootBundle.loadString('assets/quran/quran_id.json');
final List<dynamic> data = json.decode(jsonString);
_cachedSurahs = data
.map((s) => Surah.fromJson(s as Map<String, dynamic>))
.toList();
} catch (_) {
_cachedSurahs = [];
}
return _cachedSurahs!;
}
/// Get a single Surah by ID.
Future<Surah?> getSurah(int id) async {
final surahs = await getAllSurahs();
try {
return surahs.firstWhere((s) => s.id == id);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:hive_flutter/hive_flutter.dart';
/// Service for fetching Islamic-themed photos from Unsplash.
/// Implements aggressive caching to minimize API usage (1 request/day).
class UnsplashService {
static const String _baseUrl = 'https://api.unsplash.com';
static const String _cacheBoxName = 'unsplash_cache';
static const String _cacheKey = 'cached_photo';
static const String _cacheTimestampKey = 'cached_timestamp';
static const Duration _cacheTTL = Duration(hours: 24);
static final UnsplashService instance = UnsplashService._();
UnsplashService._();
// In-memory cache for the current session
Map<String, String>? _memoryCache;
/// Get a cached or fresh Islamic photo.
/// Returns a map with keys: 'imageUrl', 'photographerName', 'photographerUrl', 'unsplashUrl'
Future<Map<String, String>?> getIslamicPhoto() async {
// 1. Check memory cache
if (_memoryCache != null) return _memoryCache;
// 2. Check Hive cache
final box = await Hive.openBox(_cacheBoxName);
final cachedData = box.get(_cacheKey);
final cachedTimestamp = box.get(_cacheTimestampKey);
if (cachedData != null && cachedTimestamp != null) {
final cachedTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp);
if (DateTime.now().difference(cachedTime) < _cacheTTL) {
_memoryCache = Map<String, String>.from(json.decode(cachedData));
return _memoryCache;
}
}
// 3. Fetch from API
final photo = await _fetchFromApi();
if (photo != null) {
// Cache in Hive
await box.put(_cacheKey, json.encode(photo));
await box.put(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch);
_memoryCache = photo;
}
return photo;
}
Future<Map<String, String>?> _fetchFromApi() async {
final accessKey = dotenv.env['UNSPLASH_ACCESS_KEY'];
if (accessKey == null || accessKey.isEmpty || accessKey == 'YOUR_ACCESS_KEY_HERE') {
return null;
}
try {
final queries = ['masjid', 'kaabah', 'mosque', 'islamic architecture'];
// Rotate query based on the day of year for variety
final dayOfYear = DateTime.now().difference(DateTime(DateTime.now().year, 1, 1)).inDays;
final query = queries[dayOfYear % queries.length];
final response = await http.get(
Uri.parse('$_baseUrl/photos/random?query=$query&orientation=portrait&content_filter=high'),
headers: {'Authorization': 'Client-ID $accessKey'},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return {
'imageUrl': data['urls']?['regular'] ?? '',
'photographerName': data['user']?['name'] ?? 'Unknown',
'photographerUrl': data['user']?['links']?['html'] ?? '',
'unsplashUrl': data['links']?['html'] ?? '',
};
}
} catch (e) {
// Silent fallback — show the equalizer without background
}
return null;
}
}

View File

@@ -0,0 +1,648 @@
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 '../../../core/widgets/progress_bar.dart';
import '../../../data/local/hive_boxes.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/local/models/dzikir_log.dart';
import '../../../data/local/models/puasa_log.dart';
class ChecklistScreen extends ConsumerStatefulWidget {
const ChecklistScreen({super.key});
@override
ConsumerState<ChecklistScreen> createState() => _ChecklistScreenState();
}
class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
late String _todayKey;
late Box<DailyWorshipLog> _logBox;
late Box<AppSettings> _settingsBox;
late AppSettings _settings;
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
@override
void initState() {
super.initState();
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
_logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
_settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
_settings = _settingsBox.get('default') ?? AppSettings();
_ensureLogExists();
}
void _ensureLogExists() {
if (!_logBox.containsKey(_todayKey)) {
final shalatLogs = <String, ShalatLog>{};
for (final p in _fardhuPrayers) {
shalatLogs[p.toLowerCase()] = ShalatLog();
}
_logBox.put(
_todayKey,
DailyWorshipLog(
date: _todayKey,
shalatLogs: shalatLogs,
tilawahLog: TilawahLog(
targetValue: _settings.tilawahTargetValue,
targetUnit: _settings.tilawahTargetUnit,
autoSync: _settings.tilawahAutoSync,
),
dzikirLog: _settings.trackDzikir ? DzikirLog() : null,
puasaLog: _settings.trackPuasa ? PuasaLog() : null,
),
);
}
}
DailyWorshipLog get _todayLog => _logBox.get(_todayKey)!;
void _recalculateProgress() {
final log = _todayLog;
// Lazily attach Dzikir and Puasa if user toggles them mid-day
if (_settings.trackDzikir && log.dzikirLog == null) log.dzikirLog = DzikirLog();
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
int total = 0;
int completed = 0;
// Shalat
for (final p in _fardhuPrayers) {
final pKey = p.toLowerCase();
final sLog = log.shalatLogs[pKey];
if (sLog != null) {
total++;
if (sLog.completed) completed++;
if (hasQabliyah(pKey, _settings.rawatibLevel)) {
total++;
if (sLog.qabliyah == true) completed++;
}
if (hasBadiyah(pKey, _settings.rawatibLevel)) {
total++;
if (sLog.badiyah == true) completed++;
}
}
}
// Tilawah
if (log.tilawahLog != null) {
total++;
if (log.tilawahLog!.isCompleted) completed++;
}
// Dzikir
if (_settings.trackDzikir && log.dzikirLog != null) {
total += 2;
if (log.dzikirLog!.pagi) completed++;
if (log.dzikirLog!.petang) completed++;
}
// Puasa
if (_settings.trackPuasa && log.puasaLog != null) {
total++;
if (log.puasaLog!.completed) completed++;
}
log.totalItems = total;
log.completedCount = completed;
log.completionPercent = total > 0 ? completed / total : 0.0;
log.save();
setState(() {});
}
bool hasQabliyah(String prayer, int level) {
if (level == 0) return false;
if (prayer == 'subuh') return true;
if (prayer == 'dzuhur') return true;
if (prayer == 'ashar') return level == 2; // Ghairu Muakkad
if (prayer == 'maghrib') return level == 2; // Ghairu Muakkad
if (prayer == 'isya') return level == 2; // Ghairu Muakkad
return false;
}
bool hasBadiyah(String prayer, int level) {
if (level == 0) return false;
if (prayer == 'subuh') return false;
if (prayer == 'dzuhur') return true;
if (prayer == 'ashar') return false;
if (prayer == 'maghrib') return true;
if (prayer == 'isya') return true;
return false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final log = _todayLog;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Ibadah Harian'),
Text(
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
style: theme.textTheme.bodySmall?.copyWith(
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
],
),
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: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
const SizedBox(height: 12),
_buildProgressCard(log, isDark),
const SizedBox(height: 24),
_sectionLabel('SHOLAT FARDHU & RAWATIB'),
const SizedBox(height: 12),
..._fardhuPrayers.map((p) => _buildShalatCard(p, isDark)).toList(),
const SizedBox(height: 24),
_sectionLabel('TILAWAH AL-QURAN'),
const SizedBox(height: 12),
_buildTilawahCard(isDark),
if (_settings.trackDzikir || _settings.trackPuasa) ...[
const SizedBox(height: 24),
_sectionLabel('AMALAN TAMBAHAN'),
const SizedBox(height: 12),
],
if (_settings.trackDzikir) _buildDzikirCard(isDark),
if (_settings.trackPuasa) _buildPuasaCard(isDark),
const SizedBox(height: 32),
],
),
);
}
Widget _sectionLabel(String text) {
return Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
);
}
Widget _buildProgressCard(DailyWorshipLog log, bool isDark) {
final percent = log.completionPercent;
final remaining = log.totalItems - log.completedCount;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : const Color(0xFF2B3441),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"POIN HARI INI",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.primary.withValues(alpha: 0.8),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.stars, color: AppColors.primary, size: 14),
const SizedBox(width: 4),
Text(
'${log.totalPoints} pts',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${(percent * 100).round()}%',
style: const TextStyle(
fontSize: 42,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1.1,
),
),
const SizedBox(width: 8),
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
'Selesai',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white70,
),
),
),
],
),
const SizedBox(height: 16),
AppProgressBar(
value: percent,
height: 8,
backgroundColor: Colors.white.withValues(alpha: 0.15),
fillColor: AppColors.primary,
),
const SizedBox(height: 12),
Text(
remaining == 0 && log.totalItems > 0
? 'MasyaAllah! Poin maksimal tercapai hari ini! 🎉'
: 'Kumpulkan poin lebih banyak dengan Sholat di Masjid dan amalan sunnah lainnya!',
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildShalatCard(String prayerName, bool isDark) {
final pKey = prayerName.toLowerCase();
final log = _todayLog.shalatLogs[pKey];
if (log == null) return const SizedBox.shrink();
final hasQab = hasQabliyah(pKey, _settings.rawatibLevel);
final hasBad = hasBadiyah(pKey, _settings.rawatibLevel);
final isCompleted = log.completed;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isCompleted
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isCompleted
? AppColors.primary.withValues(alpha: 0.15)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.mosque, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
),
title: Text(
'Sholat $prayerName',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: isCompleted && isDark ? AppColors.textSecondaryDark : null,
decoration: isCompleted ? TextDecoration.lineThrough : null,
),
),
subtitle: log.location != null
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
: null,
trailing: _CustomCheckbox(
value: isCompleted,
onChanged: (v) {
log.completed = v ?? false;
_recalculateProgress();
},
),
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
children: [
const Divider(),
const SizedBox(height: 8),
// Location Radio
Row(
children: [
const Text('Pelaksanaan:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(width: 16),
_radioOption('Masjid', log, () {
log.location = 'Masjid';
log.completed = true; // Auto-check parent
_recalculateProgress();
}),
const SizedBox(width: 16),
_radioOption('Rumah', log, () {
log.location = 'Rumah';
log.completed = true; // Auto-check parent
_recalculateProgress();
}),
],
),
if (hasQab || hasBad) const SizedBox(height: 12),
if (hasQab)
_sunnahRow('Qabliyah $prayerName', log.qabliyah ?? false, (v) {
log.qabliyah = v;
_recalculateProgress();
}),
if (hasBad)
_sunnahRow('Ba\'diyah $prayerName', log.badiyah ?? false, (v) {
log.badiyah = v;
_recalculateProgress();
}),
],
),
),
);
}
Widget _radioOption(String title, ShalatLog log, VoidCallback onTap) {
final selected = log.location == title;
return GestureDetector(
onTap: onTap,
child: Row(
children: [
Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_off,
size: 18,
color: selected ? AppColors.primary : Colors.grey,
),
const SizedBox(width: 4),
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
],
),
);
}
Widget _sunnahRow(String title, bool value, ValueChanged<bool?> onChanged) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 14)),
_CustomCheckbox(value: value, onChanged: onChanged),
],
),
);
}
Widget _buildTilawahCard(bool isDark) {
final log = _todayLog.tilawahLog;
if (log == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: log.isCompleted
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
),
),
child: Column(
children: [
// ── Row 1: Target + Checkbox ──
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: log.isCompleted
? AppColors.primary.withValues(alpha: 0.15)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.menu_book, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tilawah',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
),
),
Text(
'Target: ${log.targetValue} ${log.targetUnit}',
style: const TextStyle(fontSize: 12, color: AppColors.primary),
),
],
),
),
_CustomCheckbox(
value: log.targetCompleted,
onChanged: (v) {
log.targetCompleted = v ?? false;
_recalculateProgress();
},
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// ── Row 2: Ayat Tracker ──
Row(
children: [
Icon(Icons.auto_stories, size: 18, color: AppColors.sage),
const SizedBox(width: 8),
Expanded(
child: Text(
'Sudah Baca: ${log.rawAyatRead} Ayat',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
),
if (log.autoSync)
Tooltip(
message: 'Sinkron dari Al-Quran',
child: Icon(Icons.sync, size: 16, color: AppColors.primary),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 20),
visualDensity: VisualDensity.compact,
onPressed: log.rawAyatRead > 0
? () {
log.rawAyatRead--;
_recalculateProgress();
}
: null,
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20, color: AppColors.primary),
visualDensity: VisualDensity.compact,
onPressed: () {
log.rawAyatRead++;
_recalculateProgress();
},
),
],
),
],
),
);
}
Widget _buildDzikirCard(bool isDark) {
final log = _todayLog.dzikirLog;
if (log == null) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 20, color: AppColors.sage),
const SizedBox(width: 8),
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
],
),
const SizedBox(height: 12),
_sunnahRow('Dzikir Pagi', log.pagi, (v) {
log.pagi = v ?? false;
_recalculateProgress();
}),
_sunnahRow('Dzikir Petang', log.petang, (v) {
log.petang = v ?? false;
_recalculateProgress();
}),
],
),
);
}
Widget _buildPuasaCard(bool isDark) {
final log = _todayLog.puasaLog;
if (log == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(Icons.nightlight_round, size: 20, color: AppColors.sage),
const SizedBox(width: 8),
const Expanded(child: Text('Puasa Sunnah', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
DropdownButton<String>(
value: log.jenisPuasa,
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
underline: const SizedBox(),
items: ['Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', 'Lainnya']
.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) {
log.jenisPuasa = v;
_recalculateProgress();
},
),
const SizedBox(width: 8),
_CustomCheckbox(
value: log.completed,
onChanged: (v) {
log.completed = v ?? false;
_recalculateProgress();
},
),
],
),
);
}
}
class _CustomCheckbox extends StatelessWidget {
final bool value;
final ValueChanged<bool?> onChanged;
const _CustomCheckbox({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: value ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: value ? null : Border.all(color: Colors.grey, width: 2),
),
child: value ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
),
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View 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';
});

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View 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),
),
),
],
),
),
);
}),
),
),
],
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,306 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/dzikir_counter.dart';
class DzikirScreen extends ConsumerStatefulWidget {
const DzikirScreen({super.key});
@override
ConsumerState<DzikirScreen> createState() => _DzikirScreenState();
}
class _DzikirScreenState extends ConsumerState<DzikirScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
List<Map<String, dynamic>> _pagiItems = [];
List<Map<String, dynamic>> _petangItems = [];
late Box<DzikirCounter> _counterBox;
late String _todayKey;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_counterBox = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
_loadData();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadData() async {
final pagiJson =
await rootBundle.loadString('assets/dzikir/dzikir_pagi.json');
final petangJson =
await rootBundle.loadString('assets/dzikir/dzikir_petang.json');
setState(() {
_pagiItems = List<Map<String, dynamic>>.from(json.decode(pagiJson));
_petangItems = List<Map<String, dynamic>>.from(json.decode(petangJson));
});
}
DzikirCounter _getCounter(String dzikirId, int target) {
final key = '${dzikirId}_$_todayKey';
return _counterBox.get(key) ??
DzikirCounter(
dzikirId: dzikirId,
date: _todayKey,
count: 0,
target: target,
);
}
void _increment(String dzikirId, int target) {
final key = '${dzikirId}_$_todayKey';
var counter = _counterBox.get(key);
if (counter == null) {
counter = DzikirCounter(
dzikirId: dzikirId,
date: _todayKey,
count: 1,
target: target,
);
_counterBox.put(key, counter);
} else {
if (counter.count < counter.target) {
counter.count++;
counter.save();
}
}
setState(() {});
// Haptic feedback
HapticFeedback.lightImpact();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Dzikir Pagi & Petang'),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.info_outline),
),
],
),
body: Column(
children: [
// Tabs
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: TabBar(
controller: _tabController,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
indicatorColor: AppColors.primary,
indicatorWeight: 3,
labelStyle:
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
tabs: const [
Tab(text: 'Pagi'),
Tab(text: 'Petang'),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildDzikirList(context, isDark, _pagiItems, 'pagi',
'Dzikir Pagi', 'Dibaca setelah shalat Shubuh hingga terbit matahari'),
_buildDzikirList(context, isDark, _petangItems, 'petang',
'Dzikir Petang', 'Dibaca setelah shalat Ashar hingga terbenam matahari'),
],
),
),
],
),
);
}
Widget _buildDzikirList(BuildContext context, bool isDark,
List<Map<String, dynamic>> items, String prefix, String title, String subtitle) {
if (items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
children: [
Text(title,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
fontSize: 13,
),
),
],
),
);
}
final item = items[index - 1];
final dzikirId = '${prefix}_${item['id']}';
final target = (item['count'] as num?)?.toInt() ?? 1;
final counter = _getCounter(dzikirId, target);
final isComplete = counter.count >= counter.target;
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isComplete
? AppColors.primary.withValues(alpha: 0.3)
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: count badge + number
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Text(
'$target KALI',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
Text(
'${(index).toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
const SizedBox(height: 16),
// Arabic text
SizedBox(
width: double.infinity,
child: Text(
item['arabic'] ?? '',
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
height: 2.0,
),
),
),
const SizedBox(height: 12),
// Transliteration
Text(
item['transliteration'] ?? '',
style: TextStyle(
fontSize: 13,
fontStyle: FontStyle.italic,
color: AppColors.primary,
),
),
const SizedBox(height: 8),
// Translation
Text(
'"${item['translation'] ?? ''}"',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 16),
// Counter button
GestureDetector(
onTap: () => _increment(dzikirId, target),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isComplete
? AppColors.primary.withValues(alpha: 0.15)
: AppColors.primary,
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isComplete ? Icons.check : Icons.touch_app,
size: 18,
color: isComplete
? AppColors.primary
: AppColors.onPrimary,
),
const SizedBox(width: 8),
Text(
'${counter.count} / $target',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isComplete
? AppColors.primary
: AppColors.onPrimary,
),
),
],
),
),
),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View 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,
});
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,566 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/progress_bar.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/daily_worship_log.dart';
import '../../../data/local/models/checklist_item.dart';
class LaporanScreen extends ConsumerStatefulWidget {
const LaporanScreen({super.key});
@override
ConsumerState<LaporanScreen> createState() => _LaporanScreenState();
}
class _LaporanScreenState extends ConsumerState<LaporanScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() => setState(() {}));
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
/// Get the last 7 days' point data.
List<_DayData> _getWeeklyData() {
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final now = DateTime.now();
final data = <_DayData>[];
for (int i = 6; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
final key = DateFormat('yyyy-MM-dd').format(date);
final log = logBox.get(key);
data.add(_DayData(
label: DateFormat('E').format(date).substring(0, 3),
value: (log?.totalPoints ?? 0).toDouble(), // Use points instead of %
isToday: i == 0,
));
}
return data;
}
/// Get average points for the week.
double _weekAverage(List<_DayData> data) {
if (data.isEmpty) return 0;
final sum = data.fold<double>(0, (s, d) => s + d.value);
return sum / data.length;
}
/// Find best and worst performing items.
_InsightPair _getInsights() {
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final now = DateTime.now();
final completionCounts = <String, int>{};
final totalCounts = <String, int>{};
int daysChecked = 0;
for (int i = 0; i < 7; i++) {
final date = now.subtract(Duration(days: i));
final key = DateFormat('yyyy-MM-dd').format(date);
final log = logBox.get(key);
if (log != null && log.totalItems > 0) {
daysChecked++;
// Fardhu
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
int completedFardhu = log.shalatLogs.values.where((l) => l.completed).length;
completionCounts['fardhu'] = (completionCounts['fardhu'] ?? 0) + completedFardhu;
// Rawatib
int rawatibTotal = 0;
int rawatibCompleted = 0;
for (var sLog in log.shalatLogs.values) {
if (sLog.qabliyah != null) { rawatibTotal++; if (sLog.qabliyah!) rawatibCompleted++; }
if (sLog.badiyah != null) { rawatibTotal++; if (sLog.badiyah!) rawatibCompleted++; }
}
if (rawatibTotal > 0) {
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
completionCounts['rawatib'] = (completionCounts['rawatib'] ?? 0) + rawatibCompleted;
}
// Tilawah
if (log.tilawahLog != null) {
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
if (log.tilawahLog!.isCompleted) {
completionCounts['tilawah'] = (completionCounts['tilawah'] ?? 0) + 1;
}
}
// Dzikir
if (log.dzikirLog != null) {
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
int dCompleted = (log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
completionCounts['dzikir'] = (completionCounts['dzikir'] ?? 0) + dCompleted;
}
// Puasa
if (log.puasaLog != null) {
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
if (log.puasaLog!.completed) {
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
}
}
}
}
if (daysChecked == 0 || totalCounts.isEmpty) {
return _InsightPair(
best: _InsightItem(title: 'Sholat Fardhu', percent: 0),
worst: _InsightItem(title: 'Belum Ada Data', percent: 0),
);
}
String bestId = totalCounts.keys.first;
String worstId = totalCounts.keys.first;
double bestRate = -1.0;
double worstRate = 2.0;
for (final id in totalCounts.keys) {
final total = totalCounts[id]!;
final completed = completionCounts[id] ?? 0;
final rate = completed / total;
if (rate > bestRate) {
bestRate = rate;
bestId = id;
}
if (rate < worstRate) {
worstRate = rate;
worstId = id;
}
}
final idToTitle = {
'fardhu': 'Sholat Fardhu',
'rawatib': 'Sholat Rawatib',
'tilawah': 'Tilawah Quran',
'dzikir': 'Dzikir Harian',
'puasa': 'Puasa Sunnah',
};
return _InsightPair(
best: _InsightItem(
title: idToTitle[bestId] ?? bestId,
percent: (bestRate * 100).round(),
),
worst: _InsightItem(
title: idToTitle[worstId] ?? worstId,
percent: (worstRate * 100).round(),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final weekData = _getWeeklyData();
final avgPercent = _weekAverage(weekData);
final insights = _getInsights();
return Scaffold(
appBar: AppBar(
title: const Text('Laporan Kualitas Ibadah'),
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: [
// ── Tab Bar ──
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
),
child: TabBar(
controller: _tabController,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
indicatorColor: AppColors.primary,
indicatorWeight: 3,
labelStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
),
tabs: const [
Tab(text: 'Mingguan'),
Tab(text: 'Bulanan'),
Tab(text: 'Tahunan'),
],
),
),
// ── Tab Content ──
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
_buildComingSoon(context, 'Bulanan'),
_buildComingSoon(context, 'Tahunan'),
],
),
),
],
),
);
}
Widget _buildWeeklyView(
BuildContext context,
bool isDark,
List<_DayData> weekData,
double avgPercent,
_InsightPair insights,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Completion Card ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Poin Rata-Rata Harian',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.stars,
color: AppColors.primary, size: 18),
),
],
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${avgPercent.round()} pt',
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
],
),
const SizedBox(height: 20),
// ── Bar Chart ──
SizedBox(
height: 140,
child: Builder(
builder: (context) {
final maxPts = weekData.map((d) => d.value).fold<double>(0.0, (a, b) => a > b ? a : b).clamp(50.0, 300.0);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: weekData.map((d) {
final ratio = (d.value / maxPts).clamp(0.05, 1.0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Container(
width: double.infinity,
height: 120 * ratio,
decoration: BoxDecoration(
color: d.isToday
? AppColors.primary
: AppColors.primary
.withValues(alpha: 0.3 + ratio * 0.4),
borderRadius: BorderRadius.circular(6),
),
),
),
const SizedBox(height: 8),
Text(
d.label,
style: TextStyle(
fontSize: 10,
fontWeight: d.isToday
? FontWeight.w700
: FontWeight.w400,
color: d.isToday
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
),
);
}).toList(),
);
}
),
),
],
),
),
const SizedBox(height: 24),
// ── Insights ──
Text('Wawasan',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
// Best performing
_insightCard(
context,
isDark,
icon: Icons.star,
iconBg: AppColors.primary.withValues(alpha: 0.15),
iconColor: AppColors.primary,
label: 'PALING RAJIN',
title: insights.best.title,
percent: insights.best.percent,
percentColor: AppColors.primary,
),
const SizedBox(height: 10),
// Needs improvement
_insightCard(
context,
isDark,
icon: Icons.trending_up,
iconBg: const Color(0xFFFFF3E0),
iconColor: Colors.orange,
label: 'PERLU DITINGKATKAN',
title: insights.worst.title,
percent: insights.worst.percent,
percentColor: Colors.orange,
),
const SizedBox(height: 24),
// ── Motivational Quote ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark
? AppColors.primary.withValues(alpha: 0.08)
: const Color(0xFFF5F9F0),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'',
style: TextStyle(
fontSize: 32,
color: AppColors.primary,
height: 0.8,
),
),
const SizedBox(height: 4),
Text(
'"Amal yang paling dicintai Allah adalah yang paling konsisten, meskipun sedikit."',
style: TextStyle(
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'— Shahih Bukhari',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _insightCard(
BuildContext context,
bool isDark, {
required IconData icon,
required Color iconBg,
required Color iconColor,
required String label,
required String title,
required int percent,
required Color percentColor,
}) {
return 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: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconBg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 2),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
),
Text(
'$percent%',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: percentColor,
),
),
],
),
);
}
Widget _buildComingSoon(BuildContext context, String period) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bar_chart,
size: 48, color: AppColors.primary.withValues(alpha: 0.3)),
const SizedBox(height: 12),
Text(
'Laporan $period',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'Segera hadir',
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
],
),
);
}
}
class _DayData {
final String label;
final double value;
final bool isToday;
_DayData({required this.label, required this.value, this.isToday = false});
}
class _InsightItem {
final String title;
final int percent;
_InsightItem({required this.title, required this.percent});
}
class _InsightPair {
final _InsightItem best;
final _InsightItem worst;
_InsightPair({required this.best, required this.worst});
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,391 @@
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_qiblah/flutter_qiblah.dart';
import '../../../app/theme/app_colors.dart';
class QiblaScreen extends ConsumerStatefulWidget {
const QiblaScreen({super.key});
@override
ConsumerState<QiblaScreen> createState() => _QiblaScreenState();
}
class _QiblaScreenState extends ConsumerState<QiblaScreen> {
// Fallback simulated data for environments without compass hardware (like macOS emulator)
double _qiblaAngle = 295.0; // Default Jakarta to Mecca
String _direction = 'NW';
bool _hasHardwareSupport = false;
late final Future<bool?> _deviceSupport = _checkDeviceSupport();
Future<bool?> _checkDeviceSupport() async {
if (Platform.isAndroid || Platform.isIOS) {
try {
return await FlutterQiblah.androidDeviceSensorSupport();
} catch (e) {
return false;
}
}
return false;
}
@override
void initState() {
super.initState();
// Pre-calculate static fallback
_calculateStaticQibla();
}
void _calculateStaticQibla() {
// Default to Jakarta coordinates
const lat = -6.2088;
const lng = 106.8456;
// Mecca coordinates
const meccaLat = 21.4225;
const meccaLng = 39.8262;
// Calculate qibla direction
final dLng = (meccaLng - lng) * math.pi / 180;
final lat1 = lat * math.pi / 180;
final lat2 = meccaLat * math.pi / 180;
final y = math.sin(dLng) * math.cos(lat2);
final x = math.cos(lat1) * math.sin(lat2) -
math.sin(lat1) * math.cos(lat2) * math.cos(dLng);
var bearing = math.atan2(y, x) * 180 / math.pi;
bearing = (bearing + 360) % 360;
setState(() {
_qiblaAngle = bearing;
_updateDirectionText(bearing);
});
}
void _updateDirectionText(double angle) {
if (angle >= 337.5 || angle < 22.5) {
_direction = 'N';
} else if (angle < 67.5) {
_direction = 'NE';
} else if (angle < 112.5) {
_direction = 'E';
} else if (angle < 157.5) {
_direction = 'SE';
} else if (angle < 202.5) {
_direction = 'S';
} else if (angle < 247.5) {
_direction = 'SW';
} else if (angle < 292.5) {
_direction = 'W';
} else {
_direction = 'NW';
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return FutureBuilder(
future: _deviceSupport,
builder: (_, AsyncSnapshot<bool?> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// If device has a compass sensor (true on physical phones)
if (snapshot.data == true) {
return _buildLiveQibla(context, isDark);
}
// If device lacks compass (macOS/emulators)
return _buildSimulatedQibla(context, isDark);
},
);
}
Widget _buildLiveQibla(BuildContext context, bool isDark) {
return StreamBuilder(
stream: FlutterQiblah.qiblahStream,
builder: (_, AsyncSnapshot<QiblahDirection> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final qiblahDirection = snapshot.data;
if (qiblahDirection == null) {
return Scaffold(body: Center(child: Text('Menunggu sensor arah...', style: TextStyle(color: isDark ? Colors.white : Colors.black))));
}
_updateDirectionText(qiblahDirection.qiblah);
return _buildQiblaLayout(
context: context,
isDark: isDark,
angleRad: qiblahDirection.qiblah * (math.pi / 180),
displayAngle: qiblahDirection.qiblah,
isLive: true,
);
},
);
}
Widget _buildSimulatedQibla(BuildContext context, bool isDark) {
return _buildQiblaLayout(
context: context,
isDark: isDark,
angleRad: _qiblaAngle * (math.pi / 180),
displayAngle: _qiblaAngle,
isLive: false,
);
}
Widget _buildQiblaLayout({
required BuildContext context,
required bool isDark,
required double angleRad,
required double displayAngle,
required bool isLive,
}) {
return Scaffold(
appBar: AppBar(
title: const Text('Qibla Finder'),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
border: Border.all(color: AppColors.cream),
),
child: const Icon(Icons.arrow_back, size: 18),
),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
border: Border.all(color: AppColors.cream),
),
child: Icon(isLive ? Icons.my_location : Icons.location_disabled, size: 18),
),
onPressed: () {
if (isLive) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Menggunakan sensor perangkat aktual')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Mode Simulasi: Hardware kompas tidak terdeteksi')),
);
}
},
),
],
),
body: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [AppColors.backgroundDark, AppColors.surfaceDark]
: [
AppColors.backgroundLight,
AppColors.primary.withValues(alpha: 0.05),
],
),
),
child: Column(
children: [
const SizedBox(height: 32),
// Location label
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on,
size: 16, color: AppColors.primary),
const SizedBox(width: 4),
Text(
'Mecca, Saudi Arabia',
style: TextStyle(
fontSize: 14,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
const SizedBox(height: 8),
// Degree + direction
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${displayAngle.round()}°',
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 12),
Text(
_direction,
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.w800,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 32),
// Compass
Expanded(
child: Center(
child: SizedBox(
width: 300,
height: 300,
child: CustomPaint(
painter: _CompassPainter(
qiblaAngle: angleRad,
isDark: isDark,
),
),
),
),
),
const SizedBox(height: 16),
// Calibration status
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
decoration: BoxDecoration(
color: isLive
? AppColors.primary.withValues(alpha: 0.1)
: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
),
child: Text(
isLive ? 'SENSOR AKTIF' : 'MODE SIMULASI (TIDAK ADA SENSOR)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: isLive ? AppColors.primary : Colors.orange,
),
),
),
const SizedBox(height: 48),
],
),
),
);
}
}
class _CompassPainter extends CustomPainter {
final double qiblaAngle;
final bool isDark;
_CompassPainter({required this.qiblaAngle, required this.isDark});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 8;
// Outer circle
final outerPaint = Paint()
..color = AppColors.primary.withValues(alpha: 0.15)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, radius, outerPaint);
// Inner dashed circle
final innerPaint = Paint()
..color = AppColors.primary.withValues(alpha: 0.08)
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawCircle(center, radius * 0.7, innerPaint);
// Cross lines
final crossPaint = Paint()
..color = AppColors.primary.withValues(alpha: 0.1)
..strokeWidth = 1;
canvas.drawLine(
Offset(center.dx, center.dy - radius),
Offset(center.dx, center.dy + radius),
crossPaint);
canvas.drawLine(
Offset(center.dx - radius, center.dy),
Offset(center.dx + radius, center.dy),
crossPaint);
// Diagonals
final diagOffset = radius * 0.707;
canvas.drawLine(
Offset(center.dx - diagOffset, center.dy - diagOffset),
Offset(center.dx + diagOffset, center.dy + diagOffset),
crossPaint);
canvas.drawLine(
Offset(center.dx + diagOffset, center.dy - diagOffset),
Offset(center.dx - diagOffset, center.dy + diagOffset),
crossPaint);
// Center dot
final centerDotPaint = Paint()
..color = AppColors.primary.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, 6, centerDotPaint);
// Qibla direction line
final qiblaEndX = center.dx + radius * 0.85 * math.cos(qiblaAngle - math.pi / 2);
final qiblaEndY = center.dy + radius * 0.85 * math.sin(qiblaAngle - math.pi / 2);
// Glow effect
final glowPaint = Paint()
..color = AppColors.primary.withValues(alpha: 0.3)
..strokeWidth = 6
..strokeCap = StrokeCap.round;
canvas.drawLine(center, Offset(qiblaEndX, qiblaEndY), glowPaint);
// Main line
final linePaint = Paint()
..color = AppColors.primary
..strokeWidth = 3
..strokeCap = StrokeCap.round;
canvas.drawLine(center, Offset(qiblaEndX, qiblaEndY), linePaint);
// Qibla icon circle at end
final iconPaint = Paint()..color = AppColors.primary;
canvas.drawCircle(Offset(qiblaEndX, qiblaEndY), 16, iconPaint);
// Kaaba icon (simplified)
final kaabaPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromCenter(
center: Offset(qiblaEndX, qiblaEndY),
width: 12,
height: 12,
),
kaabaPaint,
);
}
@override
bool shouldRepaint(covariant _CompassPainter oldDelegate) =>
qiblaAngle != oldDelegate.qiblaAngle;
}

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,323 @@
import 'package:flutter/material.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/quran_bookmark.dart';
import '../../../data/local/models/app_settings.dart';
class QuranBookmarksScreen extends StatefulWidget {
const QuranBookmarksScreen({super.key});
@override
State<QuranBookmarksScreen> createState() => _QuranBookmarksScreenState();
}
class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
bool _showLatin = true;
bool _showTerjemahan = true;
@override
void initState() {
super.initState();
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
_showLatin = settings.showLatin;
_showTerjemahan = settings.showTerjemahan;
}
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 isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Markah Al-Quran'),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.settings_display),
onPressed: _showDisplaySettings,
),
],
),
body: ValueListenableBuilder(
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
builder: (context, Box<QuranBookmark> box, _) {
if (box.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_border,
size: 64,
color: AppColors.primary.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'Belum ada markah',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'Tandai ayat saat membaca Al-Quran',
style: TextStyle(
fontSize: 14,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
textAlign: TextAlign.center,
),
],
),
);
}
// Filter bookmarks
final allBookmarks = box.values.toList();
final lastRead = allBookmarks.where((b) => b.isLastRead).toList();
final favorites = allBookmarks.where((b) => !b.isLastRead).toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (lastRead.isNotEmpty) ...[
const Text(
'TERAKHIR DIBACA',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
),
const SizedBox(height: 12),
_buildBookmarkCard(context, lastRead.first, isDark, box, isLastRead: true),
const SizedBox(height: 24),
],
if (favorites.isNotEmpty) ...[
const Text(
'AYAT FAVORIT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
),
const SizedBox(height: 12),
...favorites.map((fav) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildBookmarkCard(context, fav, isDark, box, isLastRead: false),
)),
],
],
);
},
),
);
}
Widget _buildBookmarkCard(BuildContext context, QuranBookmark bookmark, bool isDark, Box<QuranBookmark> box, {required bool isLastRead}) {
final dateStr = DateFormat('dd MMM yyyy, HH:mm').format(bookmark.savedAt);
return InkWell(
onTap: () => context.push('/tools/quran/${bookmark.surahId}?startVerse=${bookmark.verseId}'),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isLastRead
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.1) : AppColors.cream),
width: isLastRead ? 1.5 : 1.0,
),
boxShadow: isLastRead ? [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
)
] : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLastRead) ...[
const Icon(Icons.push_pin, size: 12, color: AppColors.primary),
const SizedBox(width: 4),
],
Text(
'QS. ${bookmark.surahName}: ${bookmark.verseId}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
onPressed: () => box.delete(bookmark.key),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: Text(
bookmark.verseText,
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 22,
height: 1.8,
),
),
),
if (_showLatin && bookmark.verseLatin != null) ...[
const SizedBox(height: 12),
Text(
bookmark.verseLatin!,
style: const TextStyle(
fontSize: 13,
fontStyle: FontStyle.italic,
color: AppColors.primary,
),
),
],
if (_showTerjemahan && bookmark.verseTranslation != null) ...[
const SizedBox(height: 8),
Text(
bookmark.verseTranslation!,
style: TextStyle(
fontSize: 14,
height: 1.6,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
],
const SizedBox(height: 16),
if (isLastRead) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => context.push('/tools/quran/${bookmark.surahId}?startVerse=${bookmark.verseId}'),
icon: const Icon(Icons.menu_book, size: 18),
label: const Text('Lanjutkan Membaca'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(height: 12),
],
Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
const SizedBox(width: 4),
Text(
'${isLastRead ? 'Ditandai' : 'Disimpan'}: $dateStr',
style: TextStyle(
fontSize: 10,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,869 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/services/equran_service.dart';
import '../../../data/services/unsplash_service.dart';
/// Quran Murattal (audio player) screen.
/// Implements full Surah playback using just_audio and EQuran v2 API.
class QuranMurattalScreen extends ConsumerStatefulWidget {
final String surahId;
final String? initialQariId;
final bool autoPlay;
const QuranMurattalScreen({
super.key,
required this.surahId,
this.initialQariId,
this.autoPlay = false,
});
@override
ConsumerState<QuranMurattalScreen> createState() =>
_QuranMurattalScreenState();
}
class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
final AudioPlayer _audioPlayer = AudioPlayer();
Map<String, dynamic>? _surahData;
bool _isLoading = true;
// Audio State Variables
bool _isPlaying = false;
bool _isBuffering = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
StreamSubscription? _positionSub;
StreamSubscription? _durationSub;
StreamSubscription? _playerStateSub;
// Qari State
late String _selectedQariId;
// Shuffle State
bool _isShuffleEnabled = false;
// Unsplash Background
Map<String, String>? _unsplashPhoto;
@override
void initState() {
super.initState();
_selectedQariId = widget.initialQariId ?? '05'; // Default to Misyari Rasyid Al-Afasi
_initDataAndPlayer();
_loadUnsplashPhoto();
}
Future<void> _loadUnsplashPhoto() async {
final photo = await UnsplashService.instance.getIslamicPhoto();
if (mounted && photo != null) {
setState(() => _unsplashPhoto = photo);
}
}
Future<void> _initDataAndPlayer() async {
final surahNum = int.tryParse(widget.surahId) ?? 1;
final data = await EQuranService.instance.getSurah(surahNum);
if (data != null && mounted) {
setState(() {
_surahData = data;
_isLoading = false;
});
_setupAudioStreamListeners();
_loadAudioSource();
} else if (mounted) {
setState(() => _isLoading = false);
}
}
void _setupAudioStreamListeners() {
_positionSub = _audioPlayer.positionStream.listen((pos) {
if (mounted) setState(() => _position = pos);
});
_durationSub = _audioPlayer.durationStream.listen((dur) {
if (mounted && dur != null) setState(() => _duration = dur);
});
_playerStateSub = _audioPlayer.playerStateStream.listen((state) {
if (!mounted) return;
setState(() {
_isPlaying = state.playing;
_isBuffering = state.processingState == ProcessingState.buffering ||
state.processingState == ProcessingState.loading;
// Auto pause and reset to 0 when finished
if (state.processingState == ProcessingState.completed) {
_audioPlayer.pause();
_audioPlayer.seek(Duration.zero);
// Auto-play next surah
final currentSurah = int.tryParse(widget.surahId) ?? 1;
if (_isShuffleEnabled) {
final random = Random();
int nextSurah = random.nextInt(114) + 1;
while (nextSurah == currentSurah) {
nextSurah = random.nextInt(114) + 1;
}
_navigateToSurahNumber(nextSurah, autoplay: true);
} else if (currentSurah < 114) {
_navigateToSurah(1);
}
}
});
});
}
Future<void> _loadAudioSource() async {
if (_surahData == null) return;
final audioUrls = _surahData!['audioFull'];
if (audioUrls != null && audioUrls[_selectedQariId] != null) {
try {
await _audioPlayer.setUrl(audioUrls[_selectedQariId]);
if (widget.autoPlay) {
_audioPlayer.play();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal memuat audio murattal')),
);
}
}
}
}
@override
void dispose() {
_positionSub?.cancel();
_durationSub?.cancel();
_playerStateSub?.cancel();
_audioPlayer.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (d.inHours > 0) {
return '${d.inHours}:$minutes:$seconds';
}
return '$minutes:$seconds';
}
void _seekRelative(int seconds) {
final newPosition = _position + Duration(seconds: seconds);
if (newPosition < Duration.zero) {
_audioPlayer.seek(Duration.zero);
} else if (newPosition > _duration) {
_audioPlayer.seek(_duration);
} else {
_audioPlayer.seek(newPosition);
}
}
void _navigateToSurah(int direction) {
final currentSurah = int.tryParse(widget.surahId) ?? 1;
final nextSurah = currentSurah + direction;
_navigateToSurahNumber(nextSurah, autoplay: true);
}
void _navigateToSurahNumber(int surahNum, {bool autoplay = false}) {
if (surahNum >= 1 && surahNum <= 114) {
context.pushReplacement('/tools/quran/$surahNum/murattal?qariId=$_selectedQariId&autoplay=$autoplay');
}
}
void _showQariSelector() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
const Text(
'Pilih Qari',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...EQuranService.qariNames.entries.map((entry) {
final isSelected = entry.key == _selectedQariId;
return ListTile(
leading: Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
color: isSelected ? AppColors.primary : Colors.grey,
),
title: Text(
entry.value,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primary : null,
),
),
onTap: () {
Navigator.pop(context);
if (!isSelected) {
setState(() => _selectedQariId = entry.key);
_loadAudioSource();
}
},
);
}),
const SizedBox(height: 16),
],
),
);
},
);
}
void _showSurahPlaylist() {
final currentSurah = int.tryParse(widget.surahId) ?? 1;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
const Text(
'Playlist Surah',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Expanded(
child: FutureBuilder<List<Map<String, dynamic>>>(
future: EQuranService.instance.getAllSurahs(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final surahs = snapshot.data!;
return ListView.builder(
controller: scrollController,
itemCount: surahs.length,
itemBuilder: (context, i) {
final surah = surahs[i];
final surahNum = surah['nomor'] ?? (i + 1);
final isCurrentSurah = surahNum == currentSurah;
return ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isCurrentSurah
? AppColors.primary
: AppColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$surahNum',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isCurrentSurah ? Colors.white : AppColors.primary,
),
),
),
),
title: Text(
surah['namaLatin'] ?? 'Surah $surahNum',
style: TextStyle(
fontWeight: isCurrentSurah ? FontWeight.bold : FontWeight.normal,
color: isCurrentSurah ? AppColors.primary : null,
),
),
subtitle: Text(
'${surah['arti'] ?? ''}${surah['jumlahAyat'] ?? 0} Ayat',
style: const TextStyle(fontSize: 12),
),
trailing: isCurrentSurah
? Icon(Icons.graphic_eq, color: AppColors.primary, size: 20)
: null,
onTap: () {
Navigator.pop(context);
if (!isCurrentSurah) {
context.pushReplacement(
'/tools/quran/$surahNum/murattal?qariId=$_selectedQariId',
);
}
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
final hasPhoto = _unsplashPhoto != null;
return Scaffold(
extendBodyBehindAppBar: hasPhoto,
appBar: AppBar(
backgroundColor: hasPhoto ? Colors.transparent : null,
elevation: hasPhoto ? 0 : null,
iconTheme: hasPhoto ? const IconThemeData(color: Colors.white) : null,
flexibleSpace: hasPhoto
? ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
color: Colors.black.withValues(alpha: 0.2),
),
),
)
: null,
title: Column(
children: [
Text(
'Surah $surahName',
style: TextStyle(
color: hasPhoto ? Colors.white : null,
),
),
Text(
'MURATTAL',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: hasPhoto ? Colors.white70 : AppColors.primary,
),
),
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Stack(
fit: StackFit.expand,
children: [
// === FULL-BLEED BACKGROUND ===
if (_unsplashPhoto != null)
CachedNetworkImage(
imageUrl: _unsplashPhoto!['imageUrl'] ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.primary.withValues(alpha: 0.1),
AppColors.primary.withValues(alpha: 0.05),
],
),
),
),
errorWidget: (context, url, error) => Container(
color: isDark ? Colors.black : Colors.grey.shade100,
),
)
else
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.primary.withValues(alpha: 0.1),
AppColors.primary.withValues(alpha: 0.03),
],
),
),
),
// Dark overlay
if (_unsplashPhoto != null)
Container(
color: Colors.black.withValues(alpha: 0.35),
),
// === CENTER CONTENT (Equalizer + Text) ===
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 280, // leave room for the player
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Equalizer circle
Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: _unsplashPhoto != null
? [
Colors.white.withValues(alpha: 0.15),
Colors.white.withValues(alpha: 0.05),
]
: [
AppColors.primary.withValues(alpha: 0.2),
AppColors.primary.withValues(alpha: 0.05),
],
),
),
child: Center(
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.1)
: AppColors.primary.withValues(alpha: 0.12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(7, (i) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: _EqualizerBar(
isPlaying: _isPlaying,
index: i,
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
);
}),
),
),
),
),
const SizedBox(height: 32),
// Qari name
Text(
EQuranService.qariNames[_selectedQariId] ?? 'Memuat...',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _unsplashPhoto != null ? Colors.white : null,
),
),
const SizedBox(height: 4),
Text(
'Memutar Surat $surahName',
style: TextStyle(
fontSize: 14,
color: _unsplashPhoto != null
? Colors.white70
: AppColors.primary,
),
),
],
),
),
),
// === FROSTED GLASS PLAYER CONTROLS ===
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: BackdropFilter(
filter: _unsplashPhoto != null
? ImageFilter.blur(sigmaX: 20, sigmaY: 20)
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 48),
decoration: BoxDecoration(
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.15)
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: _unsplashPhoto != null
? Border(
top: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
width: 0.5,
),
)
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress slider
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
),
child: Slider(
value: _position.inMilliseconds.toDouble(),
max: _duration.inMilliseconds > 0 ? _duration.inMilliseconds.toDouble() : 1.0,
onChanged: (v) {
_audioPlayer.seek(Duration(milliseconds: v.round()));
},
activeColor: _unsplashPhoto != null ? Colors.white : AppColors.primary,
inactiveColor: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.2)
: AppColors.primary.withValues(alpha: 0.15),
),
),
// Time labels
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_position),
style: TextStyle(
fontSize: 12,
color: _unsplashPhoto != null
? Colors.white70
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
),
),
Text(
_formatDuration(_duration),
style: TextStyle(
fontSize: 12,
color: _unsplashPhoto != null
? Colors.white70
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
),
),
],
),
),
const SizedBox(height: 16),
// Playback controls — Spotify-style 5-button row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Shuffle
IconButton(
onPressed: () => setState(() => _isShuffleEnabled = !_isShuffleEnabled),
icon: Icon(
Icons.shuffle_rounded,
size: 24,
color: _isShuffleEnabled
? (_unsplashPhoto != null ? Colors.white : AppColors.primary)
: (_unsplashPhoto != null
? Colors.white54
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight)),
),
),
// Previous Surah
IconButton(
onPressed: (int.tryParse(widget.surahId) ?? 1) > 1
? () => _navigateToSurah(-1)
: null,
icon: Icon(
Icons.skip_previous_rounded,
size: 36,
color: (int.tryParse(widget.surahId) ?? 1) > 1
? (_unsplashPhoto != null ? Colors.white : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight))
: Colors.grey.withValues(alpha: 0.2),
),
),
// Play/Pause
GestureDetector(
onTap: () {
if (_isPlaying) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
},
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_unsplashPhoto != null
? Colors.white
: AppColors.primary)
.withValues(alpha: 0.3),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: _isBuffering
? Padding(
padding: const EdgeInsets.all(18.0),
child: CircularProgressIndicator(
color: _unsplashPhoto != null
? Colors.black87
: Colors.white,
strokeWidth: 3,
),
)
: Icon(
_isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 36,
color: _unsplashPhoto != null
? Colors.black87
: AppColors.onPrimary,
),
),
),
// Next Surah
IconButton(
onPressed: (int.tryParse(widget.surahId) ?? 1) < 114
? () => _navigateToSurah(1)
: null,
icon: Icon(
Icons.skip_next_rounded,
size: 36,
color: (int.tryParse(widget.surahId) ?? 1) < 114
? (_unsplashPhoto != null ? Colors.white : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight))
: Colors.grey.withValues(alpha: 0.2),
),
),
// Playlist
IconButton(
onPressed: _showSurahPlaylist,
icon: Icon(
Icons.playlist_play_rounded,
size: 28,
color: _unsplashPhoto != null
? Colors.white70
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
),
),
],
),
const SizedBox(height: 16),
// Qari selector trigger
GestureDetector(
onTap: _showQariSelector,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.15)
: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person, size: 16,
color: _unsplashPhoto != null ? Colors.white : AppColors.primary),
const SizedBox(width: 8),
Text(
EQuranService.qariNames[_selectedQariId] ?? 'Ganti Qari',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _unsplashPhoto != null ? Colors.white : AppColors.primary,
),
),
const SizedBox(width: 4),
Icon(Icons.expand_more,
size: 16,
color: _unsplashPhoto != null ? Colors.white : AppColors.primary),
],
),
),
),
],
),
),
),
),
),
// === ATTRIBUTION ===
if (_unsplashPhoto != null)
Positioned(
bottom: 280,
left: 0,
right: 0,
child: GestureDetector(
onTap: () {
final url = _unsplashPhoto!['photographerUrl'];
if (url != null && url.isNotEmpty) {
launchUrl(Uri.parse('$url?utm_source=jamshalat_diary&utm_medium=referral'));
}
},
child: Text(
'📷 ${_unsplashPhoto!['photographerName']} / Unsplash',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: Colors.white.withValues(alpha: 0.6),
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}
/// Animated equalizer bar widget for the Murattal player.
class _EqualizerBar extends StatefulWidget {
final bool isPlaying;
final int index;
final Color color;
const _EqualizerBar({
required this.isPlaying,
required this.index,
required this.color,
});
@override
State<_EqualizerBar> createState() => _EqualizerBarState();
}
class _EqualizerBarState extends State<_EqualizerBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
// Each bar has a unique height range and speed for variety
static const _barConfigs = [
[0.3, 0.9, 600],
[0.2, 1.0, 500],
[0.4, 0.8, 700],
[0.1, 1.0, 450],
[0.3, 0.9, 550],
[0.2, 0.85, 650],
[0.35, 0.95, 480],
];
@override
void initState() {
super.initState();
final config = _barConfigs[widget.index % _barConfigs.length];
final minHeight = config[0] as double;
final maxHeight = config[1] as double;
final durationMs = (config[2] as num).toInt();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: durationMs),
);
_animation = Tween<double>(
begin: minHeight,
end: maxHeight,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
if (widget.isPlaying) {
_controller.repeat(reverse: true);
}
}
@override
void didUpdateWidget(covariant _EqualizerBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isPlaying && !oldWidget.isPlaying) {
_controller.repeat(reverse: true);
} else if (!widget.isPlaying && oldWidget.isPlaying) {
_controller.animateTo(0.0, duration: const Duration(milliseconds: 300));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 6,
height: 50 * _animation.value,
decoration: BoxDecoration(
color: widget.color.withValues(alpha: 0.6 + (_animation.value * 0.4)),
borderRadius: BorderRadius.circular(3),
),
);
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/local/models/quran_bookmark.dart';
import '../../../data/services/equran_service.dart';
class QuranScreen extends ConsumerStatefulWidget {
const QuranScreen({super.key});
@override
ConsumerState<QuranScreen> createState() => _QuranScreenState();
}
class _QuranScreenState extends ConsumerState<QuranScreen> {
List<Map<String, dynamic>> _surahs = [];
String _searchQuery = '';
bool _loading = true;
bool _showLatin = true;
bool _showTerjemahan = true;
@override
void initState() {
super.initState();
_loadSurahs();
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
_showLatin = settings.showLatin;
_showTerjemahan = settings.showTerjemahan;
}
Future<void> _loadSurahs() async {
final data = await EQuranService.instance.getAllSurahs();
setState(() {
_surahs = data;
_loading = false;
});
}
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 isDark = Theme.of(context).brightness == Brightness.dark;
final filtered = _searchQuery.isEmpty
? _surahs
: _surahs
.where((s) =>
(s['namaLatin'] as String? ?? '')
.toLowerCase()
.contains(_searchQuery.toLowerCase()) ||
(s['nama'] as String? ?? '').contains(_searchQuery))
.toList();
return Scaffold(
appBar: AppBar(
title: const Text('Al-Quran'),
actions: [
IconButton(
icon: const Icon(Icons.bookmark_outline),
onPressed: () => context.push('/tools/quran/bookmarks'),
),
IconButton(
icon: const Icon(Icons.settings_display),
onPressed: _showDisplaySettings,
),
],
),
body: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Container(
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: TextField(
onChanged: (v) => setState(() => _searchQuery = v),
decoration: InputDecoration(
hintText: 'Cari surah...',
prefixIcon: Icon(Icons.search,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
),
),
// Surah list
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: filtered.isEmpty
? Center(
child: Text(
_searchQuery.isEmpty
? 'Tidak dapat memuat data'
: 'Surah tidak ditemukan',
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
)
: ValueListenableBuilder(
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
builder: (context, box, _) {
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream,
),
itemBuilder: (context, i) {
final surah = filtered[i];
final number = surah['nomor'] ?? (i + 1);
final nameLatin = surah['namaLatin'] ?? '';
final nameArabic = surah['nama'] ?? '';
final totalVerses = surah['jumlahAyat'] ?? 0;
final tempatTurun = surah['tempatTurun'] ?? '';
final arti = surah['arti'] ?? '';
final hasLastRead = box.values.any((b) => b.isLastRead && b.surahId == number);
return ListTile(
onTap: () =>
context.push('/tools/quran/$number'),
contentPadding: const EdgeInsets.symmetric(
horizontal: 0, vertical: 6),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
'$number',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
),
title: Row(
children: [
Text(
nameLatin,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
if (hasLastRead) ...[
const SizedBox(width: 8),
const Icon(Icons.push_pin, size: 14, color: AppColors.primary),
],
],
),
subtitle: Text(
'$arti$totalVerses Ayat • $tempatTurun',
style: TextStyle(
fontSize: 12,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
trailing: Text(
nameArabic,
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 18,
),
),
);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,891 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/providers/theme_provider.dart';
import '../../../core/widgets/ios_toggle.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/myquran_sholat_service.dart';
import '../../../data/services/myquran_sholat_service.dart';
import '../../dashboard/data/prayer_times_provider.dart';
import 'package:intl/intl.dart';
import '../../../data/local/models/daily_worship_log.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late AppSettings _settings;
@override
void initState() {
super.initState();
final box = Hive.box<AppSettings>(HiveBoxes.settings);
_settings = box.get('default') ?? AppSettings();
}
void _saveSettings() {
_settings.save();
setState(() {});
}
bool get _isDarkMode => _settings.themeModeIndex != 1;
bool get _notificationsEnabled =>
_settings.adhanEnabled.values.any((v) => v);
String get _displayCityName {
final stored = _settings.lastCityName ?? 'Jakarta';
if (stored.contains('|')) {
return stored.split('|').first;
}
return stored;
}
void _toggleDarkMode(bool value) {
_settings.themeModeIndex = value ? 2 : 1;
_saveSettings();
ref.read(themeProvider.notifier).state =
value ? ThemeMode.dark : ThemeMode.light;
}
void _toggleNotifications(bool value) {
_settings.adhanEnabled.updateAll((key, _) => value);
_saveSettings();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Pengaturan'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Profile Card ──
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: [
// Avatar
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
AppColors.primary,
AppColors.primary.withValues(alpha: 0.6),
],
),
),
child: Center(
child: Text(
_settings.userName.isNotEmpty
? _settings.userName[0].toUpperCase()
: 'U',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_settings.userName,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
if (_settings.userEmail.isNotEmpty)
Text(
_settings.userEmail,
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
IconButton(
onPressed: () => _showEditProfileDialog(context),
icon: Icon(Icons.edit,
size: 20, color: AppColors.primary),
),
],
),
),
const SizedBox(height: 24),
// ── PREFERENCES ──
_sectionLabel('PREFERENSI'),
const SizedBox(height: 12),
_settingRow(
isDark,
icon: Icons.dark_mode,
iconColor: const Color(0xFF6C5CE7),
title: 'Mode Gelap',
trailing: IosToggle(
value: _isDarkMode,
onChanged: _toggleDarkMode,
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.notifications,
iconColor: const Color(0xFFE17055),
title: 'Notifikasi',
trailing: IosToggle(
value: _notificationsEnabled,
onChanged: _toggleNotifications,
),
),
const SizedBox(height: 24),
// ── CHECKLIST IBADAH ──
_sectionLabel('CHECKLIST IBADAH'),
const SizedBox(height: 12),
_settingRow(
isDark,
icon: Icons.mosque_outlined,
iconColor: Colors.teal,
title: 'Tingkat Sholat Rawatib',
subtitle: _settings.rawatibLevel == 0 ? 'Mati' : (_settings.rawatibLevel == 1 ? 'Muakkad Saja' : 'Lengkap (Semua)'),
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => _showRawatibDialog(context),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.menu_book,
iconColor: Colors.amber,
title: 'Target Tilawah',
subtitle: '${_settings.tilawahTargetValue} ${_settings.tilawahTargetUnit}',
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => _showTilawahDialog(context),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.sync,
iconColor: Colors.blue,
title: 'Auto-Sync Tilawah',
subtitle: 'Catat otomatis dari menu Al-Quran',
trailing: IosToggle(
value: _settings.tilawahAutoSync,
onChanged: (v) {
_settings.tilawahAutoSync = v;
_saveSettings();
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = logBox.get(todayKey);
if (log != null && log.tilawahLog != null) {
log.tilawahLog!.autoSync = v;
log.save();
}
},
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.library_add_check,
iconColor: Colors.indigo,
title: 'Amalan Tambahan',
subtitle: 'Dzikir & Puasa Sunnah',
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => _showAmalanDialog(context),
),
const SizedBox(height: 24),
// ── PRAYER SETTINGS ──
_sectionLabel('WAKTU SHOLAT'),
const SizedBox(height: 12),
_settingRow(
isDark,
icon: Icons.mosque,
iconColor: AppColors.primary,
title: 'Metode Perhitungan',
subtitle: 'Kemenag RI',
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => _showMethodDialog(context),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.location_on,
iconColor: const Color(0xFF00B894),
title: 'Lokasi',
subtitle: _displayCityName,
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => _showLocationDialog(context),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.timer,
iconColor: const Color(0xFFFDAA5E),
title: 'Waktu Iqamah',
subtitle: 'Atur per waktu sholat',
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => _showIqamahDialog(context),
),
const SizedBox(height: 24),
// ── DISPLAY ──
_sectionLabel('TAMPILAN'),
const SizedBox(height: 12),
_settingRow(
isDark,
icon: Icons.text_fields,
iconColor: const Color(0xFF636E72),
title: 'Ukuran Font Arab',
subtitle: '${_settings.arabicFontSize.round()}pt',
trailing: SizedBox(
width: 120,
child: Slider(
value: _settings.arabicFontSize,
min: 16,
max: 40,
divisions: 12,
activeColor: AppColors.primary,
onChanged: (v) {
_settings.arabicFontSize = v;
_saveSettings();
},
),
),
),
const SizedBox(height: 24),
// ── ABOUT ──
_sectionLabel('TENTANG'),
const SizedBox(height: 12),
_settingRow(
isDark,
icon: Icons.info_outline,
iconColor: AppColors.sage,
title: 'Versi Aplikasi',
subtitle: '1.0.0',
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: Icons.favorite_outline,
iconColor: Colors.red,
title: 'Beri Nilai Kami',
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () {},
),
const SizedBox(height: 24),
// ── Reset Button ──
GestureDetector(
onTap: () => _showResetDialog(context),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
width: 1.5,
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.logout, color: Colors.red, size: 20),
SizedBox(width: 8),
Text(
'Hapus Semua Data',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
),
),
const SizedBox(height: 32),
],
),
);
}
Widget _sectionLabel(String text) {
return Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
);
}
Widget _settingRow(
bool isDark, {
required IconData icon,
required Color iconColor,
required String title,
String? subtitle,
Widget? trailing,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: onTap,
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.08)
: AppColors.cream,
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: iconColor, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
if (subtitle != null)
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
if (trailing != null) trailing,
],
),
),
);
}
void _showMethodDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
title: const Text('Metode Perhitungan'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
child: const Text(
'Aplikasi ini menggunakan data resmi dari Kementerian Agama RI (Kemenag) melalui API myQuran.\n\nData Kemenag sudah standar dan akurat untuk seluruh wilayah Indonesia, sehingga tidak perlu diubah.',
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Tutup'),
),
],
),
);
}
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());
setDialogState(() {
results = res;
isSearching = false;
});
},
),
),
onSubmitted: (val) async {
if (val.trim().isEmpty) return;
setDialogState(() => isSearching = true);
final res = await MyQuranSholatService.instance
.searchCity(val.trim());
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';
_saveSettings();
// Update providers to refresh data
ref.invalidate(selectedCityIdProvider);
ref.invalidate(cityNameProvider);
Navigator.pop(ctx);
}
},
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
],
),
),
);
}
void _showEditProfileDialog(BuildContext context) {
final nameCtrl = TextEditingController(text: _settings.userName);
final emailCtrl = TextEditingController(text: _settings.userEmail);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
title: const Text('Edit Profil'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: emailCtrl,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
_settings.userName = nameCtrl.text.trim();
_settings.userEmail = emailCtrl.text.trim();
_saveSettings();
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
),
);
}
void _showIqamahDialog(BuildContext context) {
final offsets = Map<String, int>.from(_settings.iqamahOffset);
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
title: const Text('Waktu Iqamah (menit)'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
child: Column(
mainAxisSize: MainAxisSize.min,
children: offsets.entries.map((e) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 80,
child: Text(
e.key[0].toUpperCase() + e.key.substring(1),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Slider(
value: e.value.toDouble(),
min: 0,
max: 30,
divisions: 30,
label: '${e.value} min',
activeColor: AppColors.primary,
onChanged: (v) {
setDialogState(() {
offsets[e.key] = v.round();
});
},
),
),
SizedBox(
width: 40,
child: Text(
'${e.value}m',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
_settings.iqamahOffset = offsets;
_saveSettings();
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
),
),
);
}
void _showRawatibDialog(BuildContext context) {
int tempLevel = _settings.rawatibLevel;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Row(
children: [
const Text('Sholat Rawatib', style: TextStyle(fontSize: 18)),
const Spacer(),
IconButton(
icon: const Icon(Icons.info_outline, color: AppColors.primary),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (bCtx) => Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Informasi Sholat Rawatib', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
const Text('Muakkad (Sangat Ditekankan)', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary)),
const SizedBox(height: 8),
const Text('Total 10 atau 12 Rakaat:'),
const Padding(
padding: EdgeInsets.only(left: 12, top: 4),
child: Text('• 2 Rakaat sebelum Subuh\n• 2 atau 4 Rakaat sebelum Dzuhur\n• 2 Rakaat sesudah Dzuhur\n• 2 Rakaat sesudah Maghrib\n• 2 Rakaat sesudah Isya', style: TextStyle(height: 1.5)),
),
const SizedBox(height: 16),
const Text('Ghairu Muakkad (Tambahan)', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary)),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.only(left: 12),
child: Text('• Tambahan 2 Rakaat sesudah Dzuhur\n• 4 Rakaat sebelum Ashar\n• 2 Rakaat sebelum Maghrib\n• 2 Rakaat sebelum Isya', style: TextStyle(height: 1.5)),
),
const SizedBox(height: 24),
],
),
),
);
},
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<int>(
title: const Text('Mati (Tanpa Rawatib)'),
value: 0,
groupValue: tempLevel,
onChanged: (v) => setDialogState(() => tempLevel = v!),
),
RadioListTile<int>(
title: const Text('Muakkad Saja'),
value: 1,
groupValue: tempLevel,
onChanged: (v) => setDialogState(() => tempLevel = v!),
),
RadioListTile<int>(
title: const Text('Lengkap (Semua)'),
value: 2,
groupValue: tempLevel,
onChanged: (v) => setDialogState(() => tempLevel = v!),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
_settings.rawatibLevel = tempLevel;
_saveSettings();
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
),
),
);
}
void _showTilawahDialog(BuildContext context) {
final qtyCtrl = TextEditingController(text: _settings.tilawahTargetValue.toString());
String tempUnit = _settings.tilawahTargetUnit;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Target Tilawah Harian'),
content: Row(
children: [
Expanded(
flex: 1,
child: TextField(
controller: qtyCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: DropdownButtonFormField<String>(
value: tempUnit,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: ['Juz', 'Halaman', 'Ayat'].map((u) => DropdownMenuItem(value: u, child: Text(u))).toList(),
onChanged: (v) => setDialogState(() => tempUnit = v!),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
_settings.tilawahTargetValue = qty > 0 ? qty : 1;
_settings.tilawahTargetUnit = tempUnit;
_saveSettings();
// Update today's active checklist immediately
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = logBox.get(todayKey);
if (log != null && log.tilawahLog != null) {
log.tilawahLog!.targetValue = _settings.tilawahTargetValue;
log.tilawahLog!.targetUnit = _settings.tilawahTargetUnit;
log.save();
}
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
),
),
);
}
void _showAmalanDialog(BuildContext context) {
bool tDzikir = _settings.trackDzikir;
bool tPuasa = _settings.trackPuasa;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Amalan Tambahan'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(
title: const Text('Dzikir Pagi & Petang'),
value: tDzikir,
onChanged: (v) => setDialogState(() => tDzikir = v),
),
SwitchListTile(
title: const Text('Puasa Sunnah'),
value: tPuasa,
onChanged: (v) => setDialogState(() => tPuasa = v),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
_settings.trackDzikir = tDzikir;
_settings.trackPuasa = tPuasa;
_saveSettings();
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
),
),
);
}
void _showResetDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Hapus Semua Data?'),
content: const Text(
'Ini akan menghapus semua riwayat ibadah, marka quran, penghitung dzikir, dan mereset pengaturan. Tindakan ini tidak dapat dibatalkan.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () async {
await Hive.box(HiveBoxes.worshipLogs).clear();
await Hive.box(HiveBoxes.bookmarks).clear();
await Hive.box(HiveBoxes.dzikirCounters).clear();
final box = Hive.box<AppSettings>(HiveBoxes.settings);
await box.clear();
await box.put('default', AppSettings());
setState(() {
_settings = box.get('default')!;
});
ref.read(themeProvider.notifier).state = ThemeMode.system;
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Hapus'),
),
],
),
);
}
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/services/equran_service.dart';
class ToolsScreen extends ConsumerWidget {
const ToolsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Alat Islami'),
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: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AKSES CEPAT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _ToolCard(
icon: Icons.explore,
title: 'Arah\nKiblat',
color: AppColors.primary,
isDark: isDark,
onTap: () => context.push('/tools/qibla'),
),
),
const SizedBox(width: 12),
Expanded(
child: _ToolCard(
icon: Icons.menu_book,
title: 'Baca\nQuran',
color: const Color(0xFF4A90D9),
isDark: isDark,
onTap: () => context.push('/tools/quran'),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _ToolCard(
icon: Icons.auto_awesome,
title: 'Penghitung\nDzikir',
color: const Color(0xFFE8A838),
isDark: isDark,
onTap: () => context.push('/tools/dzikir'),
),
),
const SizedBox(width: 12),
Expanded(
child: _ToolCard(
icon: Icons.headphones,
title: 'Quran\nMurattal',
color: const Color(0xFF7B61FF),
isDark: isDark,
onTap: () => context.push('/tools/quran/1/murattal'),
),
),
],
),
const SizedBox(height: 32),
// Ayat Hari Ini
FutureBuilder<Map<String, dynamic>?>(
future: EQuranService.instance.getDailyAyat(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.primary.withValues(alpha: 0.08) : const Color(0xFFF5F9F0),
borderRadius: BorderRadius.circular(16),
),
child: const Center(child: CircularProgressIndicator()),
);
}
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox.shrink(); // Hide if error/no internet
}
final data = snapshot.data!;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.primary.withValues(alpha: 0.08) : const Color(0xFFF5F9F0),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Ayat Hari Ini',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
IconButton(
icon: Icon(Icons.share,
size: 18,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
onPressed: () {},
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: Text(
data['teksArab'] ?? '',
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
height: 1.8,
),
textAlign: TextAlign.right,
),
),
const SizedBox(height: 12),
Text(
'"${data['teksIndonesia'] ?? ''}"',
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'QS. ${data['surahName']}: ${data['nomorAyat']}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
),
);
},
),
],
),
),
);
}
}
class _ToolCard extends StatelessWidget {
final IconData icon;
final String title;
final Color color;
final bool isDark;
final VoidCallback onTap;
const _ToolCard({
required this.icon,
required this.title,
required this.color,
required this.isDark,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? color.withValues(alpha: 0.15)
: AppColors.cream,
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 24),
),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
height: 1.3,
),
),
],
),
),
);
}
}

25
lib/main.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app/app.dart';
import 'data/local/hive_boxes.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive and open all boxes
await initHive();
// Load environment variables
await dotenv.load(fileName: '.env');
// Seed default settings and checklist items on first launch
await seedDefaults();
runApp(
const ProviderScope(
child: App(),
),
);
}