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:
25
lib/app/app.dart
Normal file
25
lib/app/app.dart
Normal 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
175
lib/app/router.dart
Normal 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
0
lib/app/theme/.gitkeep
Normal file
60
lib/app/theme/app_colors.dart
Normal file
60
lib/app/theme/app_colors.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
70
lib/app/theme/app_text_styles.dart
Normal file
70
lib/app/theme/app_text_styles.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
90
lib/app/theme/app_theme.dart
Normal file
90
lib/app/theme/app_theme.dart
Normal 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
4
lib/app/theme/theme.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
// Barrel file for theme exports.
|
||||
export 'app_colors.dart';
|
||||
export 'app_text_styles.dart';
|
||||
export 'app_theme.dart';
|
||||
0
lib/core/providers/.gitkeep
Normal file
0
lib/core/providers/.gitkeep
Normal file
1
lib/core/providers/placeholder.dart
Normal file
1
lib/core/providers/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
12
lib/core/providers/theme_provider.dart
Normal file
12
lib/core/providers/theme_provider.dart
Normal 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;
|
||||
});
|
||||
54
lib/core/providers/tilawah_tracking_provider.dart
Normal file
54
lib/core/providers/tilawah_tracking_provider.dart
Normal 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
0
lib/core/utils/.gitkeep
Normal file
1
lib/core/utils/placeholder.dart
Normal file
1
lib/core/utils/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/core/widgets/.gitkeep
Normal file
0
lib/core/widgets/.gitkeep
Normal file
66
lib/core/widgets/bottom_nav_bar.dart
Normal file
66
lib/core/widgets/bottom_nav_bar.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/core/widgets/ios_toggle.dart
Normal file
58
lib/core/widgets/ios_toggle.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/core/widgets/placeholder.dart
Normal file
1
lib/core/widgets/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
68
lib/core/widgets/prayer_time_card.dart
Normal file
68
lib/core/widgets/prayer_time_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/core/widgets/progress_bar.dart
Normal file
69
lib/core/widgets/progress_bar.dart
Normal 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.0–1.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/core/widgets/section_header.dart
Normal file
35
lib/core/widgets/section_header.dart
Normal 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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
lib/data/local/adapters/.gitkeep
Normal file
0
lib/data/local/adapters/.gitkeep
Normal file
1
lib/data/local/adapters/placeholder.dart
Normal file
1
lib/data/local/adapters/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
119
lib/data/local/hive_boxes.dart
Normal file
119
lib/data/local/hive_boxes.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
lib/data/local/models/app_settings.dart
Normal file
101
lib/data/local/models/app_settings.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
95
lib/data/local/models/app_settings.g.dart
Normal file
95
lib/data/local/models/app_settings.g.dart
Normal 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;
|
||||
}
|
||||
50
lib/data/local/models/cached_prayer_times.dart
Normal file
50
lib/data/local/models/cached_prayer_times.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
68
lib/data/local/models/cached_prayer_times.g.dart
Normal file
68
lib/data/local/models/cached_prayer_times.g.dart
Normal 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;
|
||||
}
|
||||
34
lib/data/local/models/checklist_item.dart
Normal file
34
lib/data/local/models/checklist_item.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
56
lib/data/local/models/checklist_item.g.dart
Normal file
56
lib/data/local/models/checklist_item.g.dart
Normal 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;
|
||||
}
|
||||
86
lib/data/local/models/daily_worship_log.dart
Normal file
86
lib/data/local/models/daily_worship_log.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
70
lib/data/local/models/daily_worship_log.g.dart
Normal file
70
lib/data/local/models/daily_worship_log.g.dart
Normal 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;
|
||||
}
|
||||
26
lib/data/local/models/dzikir_counter.dart
Normal file
26
lib/data/local/models/dzikir_counter.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
50
lib/data/local/models/dzikir_counter.g.dart
Normal file
50
lib/data/local/models/dzikir_counter.g.dart
Normal 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;
|
||||
}
|
||||
17
lib/data/local/models/dzikir_log.dart
Normal file
17
lib/data/local/models/dzikir_log.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
44
lib/data/local/models/dzikir_log.g.dart
Normal file
44
lib/data/local/models/dzikir_log.g.dart
Normal 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;
|
||||
}
|
||||
17
lib/data/local/models/puasa_log.dart
Normal file
17
lib/data/local/models/puasa_log.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
44
lib/data/local/models/puasa_log.g.dart
Normal file
44
lib/data/local/models/puasa_log.g.dart
Normal 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;
|
||||
}
|
||||
42
lib/data/local/models/quran_bookmark.dart
Normal file
42
lib/data/local/models/quran_bookmark.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
62
lib/data/local/models/quran_bookmark.g.dart
Normal file
62
lib/data/local/models/quran_bookmark.g.dart
Normal 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;
|
||||
}
|
||||
25
lib/data/local/models/shalat_log.dart
Normal file
25
lib/data/local/models/shalat_log.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
50
lib/data/local/models/shalat_log.g.dart
Normal file
50
lib/data/local/models/shalat_log.g.dart
Normal 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;
|
||||
}
|
||||
35
lib/data/local/models/tilawah_log.dart
Normal file
35
lib/data/local/models/tilawah_log.dart
Normal 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;
|
||||
}
|
||||
56
lib/data/local/models/tilawah_log.g.dart
Normal file
56
lib/data/local/models/tilawah_log.g.dart
Normal 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;
|
||||
}
|
||||
0
lib/data/services/.gitkeep
Normal file
0
lib/data/services/.gitkeep
Normal file
107
lib/data/services/dzikir_service.dart
Normal file
107
lib/data/services/dzikir_service.dart
Normal 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());
|
||||
}
|
||||
108
lib/data/services/equran_service.dart
Normal file
108
lib/data/services/equran_service.dart
Normal 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',
|
||||
};
|
||||
}
|
||||
86
lib/data/services/location_service.dart
Normal file
86
lib/data/services/location_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
108
lib/data/services/myquran_sholat_service.dart
Normal file
108
lib/data/services/myquran_sholat_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
98
lib/data/services/notification_service.dart
Normal file
98
lib/data/services/notification_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1
lib/data/services/placeholder.dart
Normal file
1
lib/data/services/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
126
lib/data/services/prayer_service.dart
Normal file
126
lib/data/services/prayer_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
98
lib/data/services/quran_service.dart
Normal file
98
lib/data/services/quran_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
83
lib/data/services/unsplash_service.dart
Normal file
83
lib/data/services/unsplash_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
0
lib/features/checklist/presentation/.gitkeep
Normal file
0
lib/features/checklist/presentation/.gitkeep
Normal file
648
lib/features/checklist/presentation/checklist_screen.dart
Normal file
648
lib/features/checklist/presentation/checklist_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/checklist/presentation/placeholder.dart
Normal file
1
lib/features/checklist/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/dashboard/data/.gitkeep
Normal file
0
lib/features/dashboard/data/.gitkeep
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/location_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Represents a single prayer time entry.
|
||||
class PrayerTimeEntry {
|
||||
final String name;
|
||||
final String time; // "HH:mm"
|
||||
final bool isActive;
|
||||
PrayerTimeEntry({
|
||||
required this.name,
|
||||
required this.time,
|
||||
this.isActive = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Full day prayer schedule from myQuran API.
|
||||
class DaySchedule {
|
||||
final String cityName;
|
||||
final String province;
|
||||
final String date; // yyyy-MM-dd
|
||||
final String tanggal; // formatted date from API
|
||||
final Map<String, String> times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
|
||||
DaySchedule({
|
||||
required this.cityName,
|
||||
required this.province,
|
||||
required this.date,
|
||||
required this.tanggal,
|
||||
required this.times,
|
||||
});
|
||||
|
||||
/// Is this schedule for tomorrow?
|
||||
bool get isTomorrow {
|
||||
final todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
return date.compareTo(todayStr) > 0;
|
||||
}
|
||||
|
||||
/// Get prayer time entries as a list.
|
||||
List<PrayerTimeEntry> get prayerList {
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('HH:mm');
|
||||
final currentTime = formatter.format(now);
|
||||
|
||||
final prayers = [
|
||||
PrayerTimeEntry(name: 'Imsak', time: times['imsak'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Subuh', time: times['subuh'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Terbit', time: times['terbit'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dhuha', time: times['dhuha'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dzuhur', time: times['dzuhur'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Ashar', time: times['ashar'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Maghrib', time: times['maghrib'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Isya', time: times['isya'] ?? '-'),
|
||||
];
|
||||
|
||||
// Find the next prayer
|
||||
int activeIndex = -1;
|
||||
if (isTomorrow) {
|
||||
// User specifically requested to show tomorrow's Subuh as upcoming
|
||||
activeIndex = 1; // 0=Imsak, 1=Subuh
|
||||
} else {
|
||||
for (int i = 0; i < prayers.length; i++) {
|
||||
if (prayers[i].time != '-' && prayers[i].time.compareTo(currentTime) > 0) {
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeIndex >= 0) {
|
||||
prayers[activeIndex] = PrayerTimeEntry(
|
||||
name: prayers[activeIndex].name,
|
||||
time: prayers[activeIndex].time,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
return prayers;
|
||||
}
|
||||
|
||||
/// Get the next prayer name and time.
|
||||
PrayerTimeEntry? get nextPrayer {
|
||||
final list = prayerList;
|
||||
for (final p in list) {
|
||||
if (p.isActive) return p;
|
||||
}
|
||||
// If none active and it's today, all prayers have passed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Jakarta city ID from myQuran API.
|
||||
const _defaultCityId = '58a2fc6ed39fd083f55d4182bf88826d';
|
||||
|
||||
/// Provider for the user's selected city ID (stored in Hive settings).
|
||||
final selectedCityIdProvider = StateProvider<String>((ref) {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').last;
|
||||
}
|
||||
return _defaultCityId;
|
||||
});
|
||||
|
||||
/// Provider for today's prayer times using myQuran API.
|
||||
final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
DaySchedule? schedule;
|
||||
|
||||
// Try API first
|
||||
final jadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, today);
|
||||
|
||||
if (jadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: jadwal['tanggal'] ?? today,
|
||||
times: jadwal,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if all prayers today have passed
|
||||
if (schedule != null && !schedule.isTomorrow && schedule.nextPrayer == null) {
|
||||
// All prayers passed, fetch tomorrow's schedule
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
||||
|
||||
final tmrwJadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
if (tmrwJadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: tomorrowStr,
|
||||
tanggal: tmrwJadwal['tanggal'] ?? tomorrowStr,
|
||||
times: tmrwJadwal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schedule != null) {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
// Fallback to adhan package
|
||||
final position = await LocationService.instance.getCurrentLocation();
|
||||
double lat = position?.latitude ?? -6.2088;
|
||||
double lng = position?.longitude ?? 106.8456;
|
||||
|
||||
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
if (result != null) {
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
return DaySchedule(
|
||||
cityName: 'Jakarta',
|
||||
province: 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||
times: {
|
||||
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||
'subuh': timeFormat.format(result.fajr),
|
||||
'terbit': timeFormat.format(result.sunrise),
|
||||
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||
'dzuhur': timeFormat.format(result.dhuhr),
|
||||
'ashar': timeFormat.format(result.asr),
|
||||
'maghrib': timeFormat.format(result.maghrib),
|
||||
'isya': timeFormat.format(result.isha),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||
final monthlyScheduleProvider =
|
||||
FutureProvider.family<Map<String, Map<String, String>>, String>(
|
||||
(ref, month) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
return MyQuranSholatService.instance.getMonthlySchedule(cityId, month);
|
||||
});
|
||||
|
||||
/// Provider for current city name.
|
||||
final cityNameProvider = FutureProvider<String>((ref) async {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
if (info != null) {
|
||||
return '${info['kabko']}, ${info['prov']}';
|
||||
}
|
||||
return 'Kota Jakarta, DKI Jakarta';
|
||||
});
|
||||
0
lib/features/dashboard/domain/.gitkeep
Normal file
0
lib/features/dashboard/domain/.gitkeep
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
@@ -0,0 +1,673 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/prayer_time_card.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../data/prayer_times_provider.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Timer? _countdownTimer;
|
||||
Duration _countdown = Duration.zero;
|
||||
String _nextPrayerName = '';
|
||||
final ScrollController _prayerScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
_prayerScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown(DaySchedule schedule) {
|
||||
_countdownTimer?.cancel();
|
||||
_updateCountdown(schedule);
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_updateCountdown(schedule);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCountdown(DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
if (next != null && next.time != '-') {
|
||||
final parts = next.time.split(':');
|
||||
if (parts.length == 2) {
|
||||
final now = DateTime.now();
|
||||
var target = DateTime(now.year, now.month, now.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]));
|
||||
if (target.isBefore(now)) {
|
||||
target = target.add(const Duration(days: 1));
|
||||
}
|
||||
setState(() {
|
||||
_nextPrayerName = next.name;
|
||||
_countdown = target.difference(now);
|
||||
if (_countdown.isNegative) _countdown = Duration.zero;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatCountdown(Duration d) {
|
||||
final h = d.inHours.toString().padLeft(2, '0');
|
||||
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final prayerTimesAsync = ref.watch(prayerTimesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildHeader(context, isDark),
|
||||
const SizedBox(height: 20),
|
||||
prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule != null) {
|
||||
_startCountdown(schedule);
|
||||
return _buildHeroCard(context, schedule);
|
||||
}
|
||||
return _buildHeroCardPlaceholder(context);
|
||||
},
|
||||
loading: () => _buildHeroCardPlaceholder(context),
|
||||
error: (_, __) => _buildHeroCardPlaceholder(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrayerTimesSection(context, prayerTimesAsync),
|
||||
const SizedBox(height: 24),
|
||||
_buildChecklistSummary(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
_buildWeeklyProgress(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, bool isDark) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.primary, width: 2),
|
||||
color: AppColors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
child: const Icon(Icons.person, size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Selamat datang,',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Assalamu'alaikum",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: Icon(
|
||||
Icons.settings_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
final name = _nextPrayerName.isNotEmpty
|
||||
? _nextPrayerName
|
||||
: (next?.name ?? 'Isya');
|
||||
final time = next?.time ?? '--:--';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -20,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.schedule,
|
||||
size: 16,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'SHOLAT BERIKUTNYA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$name — $time',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Hitung mundur: ${_formatCountdown(_countdown)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// City name
|
||||
Text(
|
||||
'📍 ${schedule.cityName}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.explore, size: 18, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Arah Kiblat',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.volume_up,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCardPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.onPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrayerTimesSection(
|
||||
BuildContext context, AsyncValue<DaySchedule?> prayerTimesAsync) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true
|
||||
? 'Jadwal Sholat Besok'
|
||||
: 'Jadwal Sholat Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true ? 'BESOK' : 'HARI INI',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule == null) return const SizedBox();
|
||||
final prayers = schedule.prayerList.where(
|
||||
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
|
||||
.contains(p.name),
|
||||
).toList();
|
||||
return ListView.separated(
|
||||
controller: _prayerScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: prayers.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final p = prayers[i];
|
||||
final icon = _prayerIcon(p.name);
|
||||
// Auto-scroll to active prayer on first build
|
||||
if (p.isActive && i > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_prayerScrollController.hasClients) {
|
||||
final targetOffset = i * 124.0; // 112 width + 12 gap
|
||||
_prayerScrollController.animateTo(
|
||||
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return PrayerTimeCard(
|
||||
prayerName: p.name,
|
||||
time: p.time,
|
||||
icon: icon,
|
||||
isActive: p.isActive,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
const Center(child: Text('Gagal memuat jadwal')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _prayerIcon(String name) {
|
||||
switch (name) {
|
||||
case 'Subuh':
|
||||
return Icons.wb_twilight;
|
||||
case 'Dzuhur':
|
||||
return Icons.wb_sunny;
|
||||
case 'Ashar':
|
||||
return Icons.filter_drama;
|
||||
case 'Maghrib':
|
||||
return Icons.wb_twilight;
|
||||
case 'Isya':
|
||||
return Icons.dark_mode;
|
||||
default:
|
||||
return Icons.schedule;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChecklistSummary(BuildContext context, bool isDark) {
|
||||
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = box.get(todayKey);
|
||||
|
||||
final points = log?.totalPoints ?? 0;
|
||||
// We can assume a max "excellent" day is around 150 points for the progress ring scale
|
||||
final percent = (points / 150).clamp(0.0, 1.0);
|
||||
|
||||
// Prepare dynamic preview lines
|
||||
int fardhuCompleted = 0;
|
||||
if (log != null) {
|
||||
fardhuCompleted = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
}
|
||||
|
||||
String amalanText = 'Belum ada data';
|
||||
if (log != null) {
|
||||
List<String> aList = [];
|
||||
if (log.tilawahLog?.isCompleted == true) aList.add('Tilawah');
|
||||
if (log.puasaLog?.completed == true) aList.add('Puasa');
|
||||
if (log.dzikirLog?.pagi == true) aList.add('Dzikir');
|
||||
if (aList.isNotEmpty) {
|
||||
amalanText = aList.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Poin Ibadah Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Kumpulkan poin dengan konsisten!',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: percent,
|
||||
strokeWidth: 4,
|
||||
backgroundColor:
|
||||
AppColors.primary.withValues(alpha: 0.15),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary),
|
||||
),
|
||||
Text(
|
||||
'$points',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
|
||||
const SizedBox(height: 8),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Amalan Selesai', amalanText, points > 50),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/checklist'),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Lihat Semua Checklist',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checklistPreviewItem(BuildContext context, bool isDark, String title,
|
||||
String subtitle, bool completed) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
completed ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: AppColors.primary,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyProgress(BuildContext context, bool isDark) {
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Reverse so today is on the far right (index 6)
|
||||
final last7Days = List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
|
||||
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
final weekPoints = <int>[];
|
||||
for (final d in last7Days) {
|
||||
final k = DateFormat('yyyy-MM-dd').format(d);
|
||||
final l = box.get(k);
|
||||
weekPoints.add(l?.totalPoints ?? 0);
|
||||
}
|
||||
|
||||
// Find the max points acquired this week to scale the bars, with a minimum floor of 50
|
||||
final maxPts = weekPoints.reduce((a, b) => a > b ? a : b).clamp(50, 300);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Progres Poin Mingguan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(7, (i) {
|
||||
final val = weekPoints[i];
|
||||
final ratio = (val / maxPts).clamp(0.1, 1.0);
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 80 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: val > 0
|
||||
? AppColors.primary.withValues(
|
||||
alpha: 0.2 + ratio * 0.8)
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
daysLabels[last7Days[i].weekday - 1], // Correct localized day
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == 6 ? FontWeight.w800 : FontWeight.w600,
|
||||
color: i == 6
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/dzikir/presentation/.gitkeep
Normal file
0
lib/features/dzikir/presentation/.gitkeep
Normal file
306
lib/features/dzikir/presentation/dzikir_screen.dart
Normal file
306
lib/features/dzikir/presentation/dzikir_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/dzikir/presentation/placeholder.dart
Normal file
1
lib/features/dzikir/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/imsakiyah/presentation/.gitkeep
Normal file
0
lib/features/imsakiyah/presentation/.gitkeep
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
@@ -0,0 +1,557 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../dashboard/data/prayer_times_provider.dart';
|
||||
|
||||
class ImsakiyahScreen extends ConsumerStatefulWidget {
|
||||
const ImsakiyahScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ImsakiyahScreen> createState() => _ImsakiyahScreenState();
|
||||
}
|
||||
|
||||
class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
int _selectedMonthIndex = 0;
|
||||
late List<_MonthOption> _months;
|
||||
late AppSettings _settings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = box.get('default') ?? AppSettings();
|
||||
_months = _generateMonths();
|
||||
// Find current month
|
||||
final now = DateTime.now();
|
||||
for (int i = 0; i < _months.length; i++) {
|
||||
if (_months[i].month == now.month && _months[i].year == now.year) {
|
||||
_selectedMonthIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<_MonthOption> _generateMonths() {
|
||||
final now = DateTime.now();
|
||||
final list = <_MonthOption>[];
|
||||
for (int offset = -2; offset <= 3; offset++) {
|
||||
final date = DateTime(now.year, now.month + offset, 1);
|
||||
list.add(_MonthOption(
|
||||
label: DateFormat('MMMM yyyy').format(date),
|
||||
month: date.month,
|
||||
year: date.year,
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||
final selected = _months[_selectedMonthIndex];
|
||||
final daysInMonth =
|
||||
DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final rows = <_DayRow>[];
|
||||
|
||||
for (int d = 1; d <= daysInMonth; d++) {
|
||||
final date = DateTime(selected.year, selected.month, d);
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
|
||||
if (apiData != null && apiData.containsKey(dateStr)) {
|
||||
final times = apiData[dateStr]!;
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: times['subuh'] ?? '-',
|
||||
sunrise: times['terbit'] ?? '-',
|
||||
dhuhr: times['dzuhur'] ?? '-',
|
||||
asr: times['ashar'] ?? '-',
|
||||
maghrib: times['maghrib'] ?? '-',
|
||||
isha: times['isya'] ?? '-',
|
||||
));
|
||||
} else {
|
||||
final times =
|
||||
PrayerService.instance.getPrayerTimes(-6.2088, 106.8456, date);
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: DateFormat('HH:mm').format(times.fajr),
|
||||
sunrise: DateFormat('HH:mm').format(times.sunrise),
|
||||
dhuhr: DateFormat('HH:mm').format(times.dhuhr),
|
||||
asr: DateFormat('HH:mm').format(times.asr),
|
||||
maghrib: DateFormat('HH:mm').format(times.maghrib),
|
||||
isha: DateFormat('HH:mm').format(times.isha),
|
||||
));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _showLocationDialog(BuildContext context) {
|
||||
final searchCtrl = TextEditingController();
|
||||
bool isSearching = false;
|
||||
List<Map<String, dynamic>> results = [];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cth: Jakarta',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
if (searchCtrl.text.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) async {
|
||||
if (val.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, i) {
|
||||
final city = results[i];
|
||||
return ListTile(
|
||||
title: Text(city['lokasi'] ?? ''),
|
||||
onTap: () {
|
||||
final id = city['id'];
|
||||
final name = city['lokasi'];
|
||||
if (id != null && name != null) {
|
||||
_settings.lastCityName = '$name|$id';
|
||||
_settings.save();
|
||||
|
||||
// Update providers to refresh data
|
||||
ref.invalidate(selectedCityIdProvider);
|
||||
ref.invalidate(cityNameProvider);
|
||||
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final today = DateTime.now();
|
||||
|
||||
final selectedMonth = _months[_selectedMonthIndex];
|
||||
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final cityNameAsync = ref.watch(cityNameProvider);
|
||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Kalender Sholat'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Month Selector ──
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
itemCount: _months.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final isSelected = i == _selectedMonthIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedMonthIndex = i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_months[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? AppColors.onPrimary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Location Card ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showLocationDialog(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on,
|
||||
color: AppColors.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lokasi Anda',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.expand_more,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Table Header ──
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_headerCell('TGL', flex: 4),
|
||||
_headerCell('SUBUH', flex: 3),
|
||||
_headerCell('SYURUQ', flex: 3),
|
||||
_headerCell('DZUHUR', flex: 3),
|
||||
_headerCell('ASHAR', flex: 3),
|
||||
_headerCell('MAGH', flex: 3),
|
||||
_headerCell('ISYA', flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Table Body ──
|
||||
Expanded(
|
||||
child: monthlyDataAsync.when(
|
||||
data: (apiData) {
|
||||
final rows = _createRows(apiData);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Day column
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) {
|
||||
final rows = _createRows(null); // fallback
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerCell(String text, {int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dataCell(String value, bool isToday,
|
||||
{bool bold = false, int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: bold ? FontWeight.w700 : FontWeight.w400,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthOption {
|
||||
final String label;
|
||||
final int month;
|
||||
final int year;
|
||||
_MonthOption({required this.label, required this.month, required this.year});
|
||||
}
|
||||
|
||||
class _DayRow {
|
||||
final DateTime date;
|
||||
final String fajr, sunrise, dhuhr, asr, maghrib, isha;
|
||||
_DayRow({
|
||||
required this.date,
|
||||
required this.fajr,
|
||||
required this.sunrise,
|
||||
required this.dhuhr,
|
||||
required this.asr,
|
||||
required this.maghrib,
|
||||
required this.isha,
|
||||
});
|
||||
}
|
||||
1
lib/features/imsakiyah/presentation/placeholder.dart
Normal file
1
lib/features/imsakiyah/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/laporan/presentation/.gitkeep
Normal file
0
lib/features/laporan/presentation/.gitkeep
Normal file
566
lib/features/laporan/presentation/laporan_screen.dart
Normal file
566
lib/features/laporan/presentation/laporan_screen.dart
Normal 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});
|
||||
}
|
||||
1
lib/features/laporan/presentation/placeholder.dart
Normal file
1
lib/features/laporan/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/qibla/presentation/.gitkeep
Normal file
0
lib/features/qibla/presentation/.gitkeep
Normal file
1
lib/features/qibla/presentation/placeholder.dart
Normal file
1
lib/features/qibla/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
391
lib/features/qibla/presentation/qibla_screen.dart
Normal file
391
lib/features/qibla/presentation/qibla_screen.dart
Normal 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;
|
||||
}
|
||||
0
lib/features/quran/presentation/.gitkeep
Normal file
0
lib/features/quran/presentation/.gitkeep
Normal file
1
lib/features/quran/presentation/placeholder.dart
Normal file
1
lib/features/quran/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
323
lib/features/quran/presentation/quran_bookmarks_screen.dart
Normal file
323
lib/features/quran/presentation/quran_bookmarks_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
869
lib/features/quran/presentation/quran_murattal_screen.dart
Normal file
869
lib/features/quran/presentation/quran_murattal_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1027
lib/features/quran/presentation/quran_reading_screen.dart
Normal file
1027
lib/features/quran/presentation/quran_reading_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
260
lib/features/quran/presentation/quran_screen.dart
Normal file
260
lib/features/quran/presentation/quran_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
lib/features/settings/presentation/.gitkeep
Normal file
0
lib/features/settings/presentation/.gitkeep
Normal file
1
lib/features/settings/presentation/placeholder.dart
Normal file
1
lib/features/settings/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
891
lib/features/settings/presentation/settings_screen.dart
Normal file
891
lib/features/settings/presentation/settings_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
251
lib/features/tools/presentation/tools_screen.dart
Normal file
251
lib/features/tools/presentation/tools_screen.dart
Normal 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
25
lib/main.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user