Polish navigation, Quran flows, and sharing UX
This commit is contained in:
@@ -1,16 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show ViewFocusEvent, ViewFocusState;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/providers/theme_provider.dart';
|
||||
import '../features/dashboard/data/prayer_times_provider.dart';
|
||||
import 'router.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
|
||||
class App extends ConsumerWidget {
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
Timer? _midnightResyncTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
});
|
||||
_scheduleMidnightResync();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_midnightResyncTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
// Resync stale pressed-key state to avoid repeated KeyDown assertions.
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
}
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.invalidate(prayerTimesProvider);
|
||||
unawaited(ref.read(prayerTimesProvider.future));
|
||||
_scheduleMidnightResync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeViewFocus(ViewFocusEvent event) {
|
||||
if (event.state == ViewFocusState.focused) {
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleMidnightResync() {
|
||||
_midnightResyncTimer?.cancel();
|
||||
final now = DateTime.now();
|
||||
final nextRun = DateTime(now.year, now.month, now.day, 0, 5).isAfter(now)
|
||||
? DateTime(now.year, now.month, now.day, 0, 5)
|
||||
: DateTime(now.year, now.month, now.day + 1, 0, 5);
|
||||
final delay = nextRun.difference(now);
|
||||
_midnightResyncTimer = Timer(delay, () {
|
||||
ref.invalidate(prayerTimesProvider);
|
||||
unawaited(ref.read(prayerTimesProvider.future));
|
||||
_scheduleMidnightResync();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
|
||||
119
lib/app/icons/app_icons.dart
Normal file
119
lib/app/icons/app_icons.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hugeicons/hugeicons.dart';
|
||||
|
||||
@immutable
|
||||
class AppIconGlyph {
|
||||
const AppIconGlyph.material(this.material) : huge = null;
|
||||
const AppIconGlyph.huge(this.huge) : material = null;
|
||||
|
||||
final IconData? material;
|
||||
final List<List<dynamic>>? huge;
|
||||
}
|
||||
|
||||
class AppIcon extends StatelessWidget {
|
||||
const AppIcon({
|
||||
super.key,
|
||||
required this.glyph,
|
||||
this.color,
|
||||
this.size,
|
||||
this.strokeWidth,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final AppIconGlyph glyph;
|
||||
final Color? color;
|
||||
final double? size;
|
||||
final double? strokeWidth;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final huge = glyph.huge;
|
||||
if (huge != null) {
|
||||
return HugeIcon(
|
||||
icon: huge,
|
||||
color: color,
|
||||
size: size,
|
||||
strokeWidth: strokeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(
|
||||
glyph.material,
|
||||
color: color,
|
||||
size: size,
|
||||
semanticLabel: semanticLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppIcons {
|
||||
const AppIcons._();
|
||||
|
||||
static const AppIconGlyph home =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedHome01);
|
||||
static const AppIconGlyph calendar =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCalendar01);
|
||||
static const AppIconGlyph quran =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran02);
|
||||
static const AppIconGlyph dzikir =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedTasbih);
|
||||
static const AppIconGlyph lainnya =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedGridView);
|
||||
static const AppIconGlyph ibadah =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckList);
|
||||
static const AppIconGlyph laporan =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedChart01);
|
||||
|
||||
static const AppIconGlyph murattal =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedHeadset);
|
||||
static const AppIconGlyph qibla =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCompass);
|
||||
static const AppIconGlyph doa = AppIconGlyph.huge(HugeIcons.strokeRoundedDua);
|
||||
static const AppIconGlyph hadits =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedBooks01);
|
||||
static const AppIconGlyph quranEnrichment =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran01);
|
||||
|
||||
static const AppIconGlyph notification =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedNotification03);
|
||||
static const AppIconGlyph settings =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedSettings02);
|
||||
static const AppIconGlyph share =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedShare01);
|
||||
static const AppIconGlyph themeMoon =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedMoon02);
|
||||
static const AppIconGlyph themeSun =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedSun02);
|
||||
static const AppIconGlyph checkCircle =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckmarkCircle02);
|
||||
static const AppIconGlyph circle =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCircle);
|
||||
static const AppIconGlyph musicNote =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedMusicNote01);
|
||||
static const AppIconGlyph shuffle =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedShuffle);
|
||||
static const AppIconGlyph previousTrack =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPrevious);
|
||||
static const AppIconGlyph nextTrack =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedNext);
|
||||
static const AppIconGlyph play =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPlay);
|
||||
static const AppIconGlyph pause =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPause);
|
||||
static const AppIconGlyph playlist =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPlaylist01);
|
||||
static const AppIconGlyph user =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedUser03);
|
||||
static const AppIconGlyph arrowDown =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowDown01);
|
||||
|
||||
static const AppIconGlyph backArrow =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowLeft01);
|
||||
static const AppIconGlyph location =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedMosqueLocation);
|
||||
static const AppIconGlyph locationActive =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedLocation01);
|
||||
static const AppIconGlyph locationOffline =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedLocationOffline01);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../data/local/hive_boxes.dart';
|
||||
@@ -19,6 +21,7 @@ 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/quran/presentation/quran_enrichment_screen.dart';
|
||||
import '../features/notifications/presentation/notification_center_screen.dart';
|
||||
import '../features/settings/presentation/settings_screen.dart';
|
||||
|
||||
/// Navigation key for the shell navigator (bottom-nav screens).
|
||||
@@ -33,12 +36,16 @@ final GoRouter appRouter = GoRouter(
|
||||
// ── Shell route (bottom nav persists) ──
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
builder: (context, state, child) => _ScaffoldWithNav(child: child),
|
||||
pageBuilder: (context, state, child) => NoTransitionPage(
|
||||
key: ValueKey<String>('shell-${state.pageKey.value}'),
|
||||
child: _ScaffoldWithNav(child: child),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: DashboardScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const DashboardScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -50,26 +57,30 @@ final GoRouter appRouter = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: '/imsakiyah',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ImsakiyahScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ImsakiyahScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/checklist',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ChecklistScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ChecklistScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/laporan',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: LaporanScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const LaporanScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tools',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ToolsScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ToolsScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -97,8 +108,10 @@ final GoRouter appRouter = GoRouter(
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
|
||||
final startVerse = int.tryParse(
|
||||
state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(
|
||||
surahId: surahId, initialVerse: startVerse);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -107,9 +120,10 @@ final GoRouter appRouter = GoRouter(
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final qariId = state.uri.queryParameters['qariId'];
|
||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
||||
final autoplay =
|
||||
state.uri.queryParameters['autoplay'] == 'true';
|
||||
return QuranMurattalScreen(
|
||||
surahId: surahId,
|
||||
surahId: surahId,
|
||||
initialQariId: qariId,
|
||||
autoPlay: autoplay,
|
||||
);
|
||||
@@ -139,7 +153,8 @@ final GoRouter appRouter = GoRouter(
|
||||
// Simple Mode Tab: Zikir
|
||||
GoRoute(
|
||||
path: '/dzikir',
|
||||
builder: (context, state) => const DzikirScreen(isSimpleModeTab: true),
|
||||
builder: (context, state) =>
|
||||
const DzikirScreen(isSimpleModeTab: true),
|
||||
),
|
||||
// Simple Mode Tab: Tilawah
|
||||
GoRoute(
|
||||
@@ -148,18 +163,24 @@ final GoRouter appRouter = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'enrichment',
|
||||
builder: (context, state) => const QuranEnrichmentScreen(),
|
||||
builder: (context, state) =>
|
||||
const QuranEnrichmentScreen(isSimpleModeTab: true),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'bookmarks',
|
||||
builder: (context, state) => const QuranBookmarksScreen(),
|
||||
builder: (context, state) =>
|
||||
const QuranBookmarksScreen(isSimpleModeTab: true),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':surahId',
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse, isSimpleModeTab: true);
|
||||
final startVerse =
|
||||
int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(
|
||||
surahId: surahId,
|
||||
initialVerse: startVerse,
|
||||
isSimpleModeTab: true);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -168,9 +189,10 @@ final GoRouter appRouter = GoRouter(
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final qariId = state.uri.queryParameters['qariId'];
|
||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
||||
final autoplay =
|
||||
state.uri.queryParameters['autoplay'] == 'true';
|
||||
return QuranMurattalScreen(
|
||||
surahId: surahId,
|
||||
surahId: surahId,
|
||||
initialQariId: qariId,
|
||||
autoPlay: autoplay,
|
||||
isSimpleModeTab: true,
|
||||
@@ -187,11 +209,17 @@ final GoRouter appRouter = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: '/hadits',
|
||||
builder: (context, state) => const HaditsScreen(isSimpleModeTab: true),
|
||||
builder: (context, state) =>
|
||||
const HaditsScreen(isSimpleModeTab: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Settings (pushed, no bottom nav) ──
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const NotificationCenterScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
@@ -201,11 +229,31 @@ final GoRouter appRouter = GoRouter(
|
||||
);
|
||||
|
||||
/// Scaffold wrapper that provides the persistent bottom nav bar.
|
||||
class _ScaffoldWithNav extends StatelessWidget {
|
||||
class _ScaffoldWithNav extends StatefulWidget {
|
||||
const _ScaffoldWithNav({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_ScaffoldWithNav> createState() => _ScaffoldWithNavState();
|
||||
}
|
||||
|
||||
class _ScaffoldWithNavState extends State<_ScaffoldWithNav> {
|
||||
DateTime? _lastBackPressedAt;
|
||||
|
||||
bool _shouldHideBottomNav({
|
||||
required bool isSimpleMode,
|
||||
required String path,
|
||||
}) {
|
||||
if (!isSimpleMode) return false;
|
||||
if (path == '/dzikir') return true;
|
||||
if (!path.startsWith('/quran/')) return false;
|
||||
|
||||
final tail = path.substring('/quran/'.length);
|
||||
if (tail == 'bookmarks' || tail == 'enrichment') return false;
|
||||
return !tail.contains('/');
|
||||
}
|
||||
|
||||
/// Maps route locations to bottom nav indices.
|
||||
int _currentIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
@@ -214,9 +262,13 @@ class _ScaffoldWithNav extends StatelessWidget {
|
||||
|
||||
if (isSimpleMode) {
|
||||
if (location.startsWith('/imsakiyah')) return 1;
|
||||
if (location.startsWith('/quran') && !location.contains('/murattal')) return 2;
|
||||
if (location.contains('/murattal')) return 3;
|
||||
if (location.startsWith('/dzikir')) return 4;
|
||||
if (location.startsWith('/quran')) return 2;
|
||||
if (location.startsWith('/dzikir')) return 3;
|
||||
if (location.startsWith('/tools') ||
|
||||
location.startsWith('/doa') ||
|
||||
location.startsWith('/hadits')) {
|
||||
return 4;
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
if (location.startsWith('/imsakiyah')) return 1;
|
||||
@@ -243,10 +295,10 @@ class _ScaffoldWithNav extends StatelessWidget {
|
||||
context.go('/quran');
|
||||
break;
|
||||
case 3:
|
||||
context.push('/quran/1/murattal');
|
||||
context.go('/dzikir');
|
||||
break;
|
||||
case 4:
|
||||
context.go('/dzikir');
|
||||
context.go('/tools');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -270,16 +322,97 @@ class _ScaffoldWithNav extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isMainShellRoute({
|
||||
required bool isSimpleMode,
|
||||
required String path,
|
||||
}) {
|
||||
if (isSimpleMode) {
|
||||
return path == '/' ||
|
||||
path == '/imsakiyah' ||
|
||||
path == '/quran' ||
|
||||
path == '/dzikir' ||
|
||||
path == '/tools';
|
||||
}
|
||||
|
||||
return path == '/' ||
|
||||
path == '/imsakiyah' ||
|
||||
path == '/checklist' ||
|
||||
path == '/laporan' ||
|
||||
path == '/tools';
|
||||
}
|
||||
|
||||
Future<void> _handleMainRouteBack(
|
||||
BuildContext context, {
|
||||
required String path,
|
||||
}) async {
|
||||
if (path != '/') {
|
||||
context.go('/');
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final pressedRecently = _lastBackPressedAt != null &&
|
||||
now.difference(_lastBackPressedAt!) <= const Duration(seconds: 2);
|
||||
|
||||
if (pressedRecently) {
|
||||
await SystemNavigator.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
_lastBackPressedAt = now;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tekan sekali lagi untuk keluar'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||
builder: (context, box, _) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: AppBottomNavBar(
|
||||
currentIndex: _currentIndex(context),
|
||||
onTap: (i) => _onTap(context, i),
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final path = GoRouterState.of(context).uri.path;
|
||||
final hideBottomNav = _shouldHideBottomNav(
|
||||
isSimpleMode: isSimpleMode,
|
||||
path: path,
|
||||
);
|
||||
final modeScopedChild = KeyedSubtree(
|
||||
key: ValueKey(
|
||||
'shell:$path:${isSimpleMode ? 'simple' : 'full'}',
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
final pageBody = hideBottomNav
|
||||
? SafeArea(
|
||||
top: false,
|
||||
child: modeScopedChild,
|
||||
)
|
||||
: modeScopedChild;
|
||||
|
||||
final handleBackInShell =
|
||||
defaultTargetPlatform == TargetPlatform.android &&
|
||||
_isMainShellRoute(isSimpleMode: isSimpleMode, path: path);
|
||||
|
||||
return PopScope(
|
||||
canPop: !handleBackInShell,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop || !handleBackInShell) return;
|
||||
await _handleMainRouteBack(context, path: path);
|
||||
},
|
||||
child: Scaffold(
|
||||
body: pageBody,
|
||||
bottomNavigationBar: hideBottomNav
|
||||
? null
|
||||
: AppBottomNavBar(
|
||||
currentIndex: _currentIndex(context),
|
||||
onTap: (i) => _onTap(context, i),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,29 +4,55 @@ import 'package:flutter/material.dart';
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── Primary ──
|
||||
static const Color primary = Color(0xFF70DF20);
|
||||
static const Color onPrimary = Color(0xFF0A1A00);
|
||||
// ── Brand tokens: logo palette (teal + gold) ──
|
||||
static const Color brandTeal500 = Color(0xFF118A8D);
|
||||
static const Color brandTeal700 = Color(0xFF0C676A);
|
||||
static const Color brandTeal900 = Color(0xFF0A4447);
|
||||
|
||||
// ── Background ──
|
||||
static const Color backgroundLight = Color(0xFFF7F8F6);
|
||||
static const Color backgroundDark = Color(0xFF182111);
|
||||
static const Color brandGold200 = Color(0xFFF6DE96);
|
||||
static const Color brandGold300 = Color(0xFFE9C75B);
|
||||
static const Color brandGold400 = Color(0xFFD6A21D);
|
||||
static const Color brandGold700 = Color(0xFF8B6415);
|
||||
|
||||
// ── Theme base tokens ──
|
||||
static const Color backgroundLight = Color(0xFFF3F4F6);
|
||||
static const Color backgroundDark = Color(0xFF0F1217);
|
||||
|
||||
// ── Surface ──
|
||||
static const Color surfaceLight = Color(0xFFFFFFFF);
|
||||
static const Color surfaceDark = Color(0xFF1E2A14);
|
||||
static const Color surfaceLightElevated = Color(0xFFF9FAFB);
|
||||
|
||||
// ── Sage (secondary text / section labels) ──
|
||||
static const Color sage = Color(0xFF728764);
|
||||
static const Color surfaceDark = Color(0xFF171B22);
|
||||
static const Color surfaceDarkElevated = Color(0xFF1D222B);
|
||||
|
||||
// ── Cream (dividers, borders — light mode only) ──
|
||||
static const Color cream = Color(0xFFF2F4F0);
|
||||
static const Color textPrimaryLight = Color(0xFF1F2937);
|
||||
static const Color textPrimaryDark = Color(0xFFE8ECF2);
|
||||
static const Color textSecondaryLight = Color(0xFF6B7280);
|
||||
static const Color textSecondaryDark = Color(0xFF9AA4B2);
|
||||
|
||||
// ── 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);
|
||||
// ── Compatibility aliases (existing UI references) ──
|
||||
static const Color primary = brandTeal500;
|
||||
static const Color onPrimary = Color(0xFFFFFFFF);
|
||||
static const Color sage = brandTeal700;
|
||||
static const Color cream = Color(0xFFE5E7EB);
|
||||
|
||||
// ── Luxury active-state tokens ──
|
||||
static const Color navActiveGold = brandGold400;
|
||||
static const Color navActiveGoldBright = brandGold300;
|
||||
static const Color navActiveGoldPale = brandGold200;
|
||||
static const Color navActiveGoldDeep = brandGold700;
|
||||
|
||||
static const Color navActiveSurfaceDark = surfaceDarkElevated;
|
||||
static const Color navActiveSurfaceLight = surfaceLight;
|
||||
|
||||
static const Color navGlowDark = Color(0x5CD6A21D);
|
||||
static const Color navGlowLight = Color(0x36D6A21D);
|
||||
static const Color navShadowLight = Color(0x1F0F172A);
|
||||
static const Color navStrokeNeutralDark = Color(0x33FFFFFF);
|
||||
static const Color navStrokeNeutralLight = Color(0x220F172A);
|
||||
|
||||
static const Color navEmbossHighlight = Color(0xE6FFFFFF);
|
||||
static const Color navEmbossShadow = Color(0x2B0F172A);
|
||||
static const Color navEmbossGoldShadow = Color(0x42B88912);
|
||||
|
||||
// ── Semantic ──
|
||||
static const Color errorLight = Color(0xFFEF4444);
|
||||
@@ -36,25 +62,29 @@ class AppColors {
|
||||
|
||||
// ── Convenience helpers for theme building ──
|
||||
|
||||
static ColorScheme get lightColorScheme => ColorScheme.light(
|
||||
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
primaryContainer: brandTeal700,
|
||||
onPrimaryContainer: Colors.white,
|
||||
surface: surfaceLight,
|
||||
onSurface: textPrimaryLight,
|
||||
error: errorLight,
|
||||
onError: Colors.white,
|
||||
secondary: sage,
|
||||
onSecondary: Colors.white,
|
||||
secondary: navActiveGold,
|
||||
onSecondary: brandGold700,
|
||||
);
|
||||
|
||||
static ColorScheme get darkColorScheme => ColorScheme.dark(
|
||||
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
primaryContainer: brandTeal900,
|
||||
onPrimaryContainer: textPrimaryDark,
|
||||
surface: surfaceDark,
|
||||
onSurface: textPrimaryDark,
|
||||
error: errorDark,
|
||||
onError: Colors.black,
|
||||
secondary: sage,
|
||||
onSecondary: Colors.white,
|
||||
secondary: navActiveGold,
|
||||
onSecondary: brandGold200,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Typography definitions from PRD §3.2.
|
||||
/// Plus Jakarta Sans (bundled) for UI text, Amiri (bundled) for Arabic content.
|
||||
/// Plus Jakarta Sans (bundled) for UI text.
|
||||
/// Scheherazade New (bundled) for Arabic/Quran text, with Uthman/KFGQPC fallback.
|
||||
class AppTextStyles {
|
||||
AppTextStyles._();
|
||||
|
||||
static const String _fontFamily = 'PlusJakartaSans';
|
||||
static const String _arabicFontFamily = 'ScheherazadeNew';
|
||||
static const List<String> _arabicFallbackFamilies = <String>[
|
||||
'UthmanTahaNaskh',
|
||||
'KFGQPCUthmanicHafs',
|
||||
'Amiri',
|
||||
'Noto Naskh Arabic',
|
||||
'Noto Sans Arabic',
|
||||
'Droid Arabic Naskh',
|
||||
'sans-serif',
|
||||
];
|
||||
|
||||
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
|
||||
static const TextTheme textTheme = TextTheme(
|
||||
@@ -52,19 +63,21 @@ class AppTextStyles {
|
||||
),
|
||||
);
|
||||
|
||||
// ── Arabic text styles (Amiri — bundled font) ──
|
||||
// ── Arabic text styles (Scheherazade New — bundled font) ──
|
||||
|
||||
static const TextStyle arabicBody = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontFamily: _arabicFontFamily,
|
||||
fontFamilyFallback: _arabicFallbackFamilies,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
height: 1.8,
|
||||
);
|
||||
|
||||
static const TextStyle arabicLarge = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontFamily: _arabicFontFamily,
|
||||
fontFamilyFallback: _arabicFallbackFamilies,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.2,
|
||||
height: 2.0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,17 +29,17 @@ class AppTheme {
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surfaceLight,
|
||||
selectedItemColor: AppColors.primary,
|
||||
selectedItemColor: AppColors.navActiveGoldDeep,
|
||||
unselectedItemColor: AppColors.textSecondaryLight,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surfaceLight,
|
||||
color: AppColors.surfaceLightElevated,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
side: const BorderSide(
|
||||
color: AppColors.cream,
|
||||
),
|
||||
),
|
||||
@@ -70,21 +70,21 @@ class AppTheme {
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surfaceDark,
|
||||
selectedItemColor: AppColors.primary,
|
||||
selectedItemColor: AppColors.navActiveGold,
|
||||
unselectedItemColor: AppColors.textSecondaryDark,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surfaceDark,
|
||||
color: AppColors.surfaceDarkElevated,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
color: AppColors.brandTeal500.withValues(alpha: 0.22),
|
||||
),
|
||||
),
|
||||
),
|
||||
dividerColor: AppColors.surfaceDark,
|
||||
dividerColor: AppColors.surfaceDarkElevated,
|
||||
);
|
||||
}
|
||||
|
||||
11
lib/core/services/app_audio_player.dart
Normal file
11
lib/core/services/app_audio_player.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
/// Shared app-wide audio player.
|
||||
///
|
||||
/// `just_audio_background` supports only one `AudioPlayer` instance, so all
|
||||
/// playback surfaces should reuse this singleton.
|
||||
class AppAudioPlayer {
|
||||
AppAudioPlayer._();
|
||||
|
||||
static final AudioPlayer instance = AudioPlayer();
|
||||
}
|
||||
94
lib/core/widgets/arabic_text.dart
Normal file
94
lib/core/widgets/arabic_text.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Arabic text widget that reacts to [AppSettings.arabicFontSize].
|
||||
///
|
||||
/// `baseFontSize` keeps per-screen visual hierarchy while still following
|
||||
/// global user preference from Settings.
|
||||
class ArabicText extends StatelessWidget {
|
||||
const ArabicText(
|
||||
this.data, {
|
||||
super.key,
|
||||
this.baseFontSize = 24,
|
||||
this.fontWeight = FontWeight.w400,
|
||||
this.height,
|
||||
this.color,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textDirection,
|
||||
this.fontStyle,
|
||||
this.letterSpacing,
|
||||
});
|
||||
|
||||
final String data;
|
||||
final double baseFontSize;
|
||||
final FontWeight fontWeight;
|
||||
final double? height;
|
||||
final Color? color;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextDirection? textDirection;
|
||||
final FontStyle? fontStyle;
|
||||
final double? letterSpacing;
|
||||
static const double _explicitLineHeightCompression = 0.9;
|
||||
static const double _defaultArabicLineHeight = 1.8;
|
||||
static const String _primaryArabicFontFamily = 'ScheherazadeNew';
|
||||
static const List<String> _arabicFallbackFamilies = <String>[
|
||||
'UthmanTahaNaskh',
|
||||
'KFGQPCUthmanicHafs',
|
||||
'Amiri',
|
||||
'Noto Naskh Arabic',
|
||||
'Noto Sans Arabic',
|
||||
'Droid Arabic Naskh',
|
||||
'sans-serif',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings)
|
||||
.listenable(keys: ['default']),
|
||||
builder: (_, box, __) {
|
||||
final preferredSize = box.get('default')?.arabicFontSize ?? 24.0;
|
||||
final adjustedSize = (baseFontSize + (preferredSize - 24.0))
|
||||
.clamp(12.0, 56.0)
|
||||
.toDouble();
|
||||
final effectiveHeight = height == null
|
||||
? _defaultArabicLineHeight
|
||||
: (height! * _explicitLineHeightCompression)
|
||||
.clamp(1.6, 2.35)
|
||||
.toDouble();
|
||||
|
||||
return Text(
|
||||
data,
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textDirection: textDirection,
|
||||
strutStyle: StrutStyle(
|
||||
fontFamily: _primaryArabicFontFamily,
|
||||
fontSize: adjustedSize,
|
||||
height: effectiveHeight,
|
||||
leading: 0.08,
|
||||
forceStrutHeight: true,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: _primaryArabicFontFamily,
|
||||
fontFamilyFallback: _arabicFallbackFamilies,
|
||||
fontSize: adjustedSize,
|
||||
fontWeight: fontWeight,
|
||||
height: effectiveHeight,
|
||||
color: color,
|
||||
fontStyle: fontStyle,
|
||||
letterSpacing: letterSpacing,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
686
lib/core/widgets/ayat_share_sheet.dart
Normal file
686
lib/core/widgets/ayat_share_sheet.dart
Normal file
@@ -0,0 +1,686 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../core/widgets/arabic_text.dart';
|
||||
|
||||
String buildAyatShareText(Map<String, dynamic> ayat) {
|
||||
final arabic = (ayat['teksArab'] ?? '').toString().trim();
|
||||
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
|
||||
final surahName = (ayat['surahName'] ?? '').toString().trim();
|
||||
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
|
||||
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
|
||||
? 'QS. $surahName: $verseNumber'
|
||||
: 'Ayat Hari Ini';
|
||||
|
||||
final parts = <String>[
|
||||
if (arabic.isNotEmpty) arabic,
|
||||
if (translation.isNotEmpty) '"$translation"',
|
||||
reference,
|
||||
'Dibagikan dari Jam Shalat Diary',
|
||||
];
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
Future<void> showAyatShareSheet(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> ayat,
|
||||
) async {
|
||||
final shareText = buildAyatShareText(ayat);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
Future<void> handleShareImage() async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
|
||||
try {
|
||||
final pngBytes = await _captureAyatShareCardPng(context, ayat);
|
||||
final file = await _writeAyatShareImage(pngBytes);
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text: 'Ayat Hari Ini',
|
||||
subject: 'Ayat Hari Ini',
|
||||
);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Gagal menyiapkan gambar ayat'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleShareText() async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
await Share.share(
|
||||
shareText,
|
||||
subject: 'Ayat Hari Ini',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleCopyText() async {
|
||||
await Clipboard.setData(ClipboardData(text: shareText));
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Teks ayat disalin ke clipboard'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Bagikan Ayat',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pilih cara tercepat untuk membagikan ayat hari ini.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AyatShareActionTile(
|
||||
icon: const Icon(
|
||||
LucideIcons.image,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: 'Bagikan Gambar',
|
||||
subtitle: 'Kirim kartu ayat yang siap dibagikan',
|
||||
badge: 'Utama',
|
||||
onTap: handleShareImage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_AyatShareActionTile(
|
||||
icon: const AppIcon(
|
||||
glyph: AppIcons.share,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: 'Bagikan Teks',
|
||||
subtitle: 'Kirim ayat dan terjemahan ke aplikasi lain',
|
||||
onTap: handleShareText,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_AyatShareActionTile(
|
||||
icon: const Icon(
|
||||
LucideIcons.copy,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: 'Salin Teks',
|
||||
subtitle: 'Simpan ke clipboard untuk ditempel manual',
|
||||
onTap: handleCopyText,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _captureAyatShareCardPng(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> ayat,
|
||||
) async {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
final boundaryKey = GlobalKey();
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final textDirection = Directionality.of(context);
|
||||
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => IgnorePointer(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Opacity(
|
||||
opacity: 0.01,
|
||||
child: MediaQuery(
|
||||
data: mediaQuery,
|
||||
child: Theme(
|
||||
data: theme,
|
||||
child: Directionality(
|
||||
textDirection: textDirection,
|
||||
child: UnconstrainedBox(
|
||||
constrainedAxis: Axis.horizontal,
|
||||
child: RepaintBoundary(
|
||||
key: boundaryKey,
|
||||
child: _AyatShareCard(
|
||||
ayat: ayat,
|
||||
isDark: theme.brightness == Brightness.dark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry);
|
||||
|
||||
try {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
final boundary = boundaryKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null) {
|
||||
throw StateError('Ayat share card is not ready');
|
||||
}
|
||||
|
||||
final image = await boundary.toImage(pixelRatio: 3);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) {
|
||||
throw StateError('Failed to encode ayat share card');
|
||||
}
|
||||
return byteData.buffer.asUint8List();
|
||||
} finally {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _writeAyatShareImage(Uint8List pngBytes) async {
|
||||
final directory = await Directory.systemTemp.createTemp('jamshalat_ayat_');
|
||||
final file = File('${directory.path}/ayat_hari_ini.png');
|
||||
await file.writeAsBytes(pngBytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
|
||||
class _AyatShareCard extends StatelessWidget {
|
||||
const _AyatShareCard({
|
||||
required this.ayat,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> ayat;
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final arabic = (ayat['teksArab'] ?? '').toString().trim();
|
||||
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
|
||||
final surahName = (ayat['surahName'] ?? '').toString().trim();
|
||||
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
|
||||
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
|
||||
? 'QS. $surahName: $verseNumber'
|
||||
: 'Ayat Hari Ini';
|
||||
final isLongArabic = arabic.length > 120;
|
||||
final isVeryLongArabic = arabic.length > 180;
|
||||
final isLongTranslation = translation.length > 140;
|
||||
final isVeryLongTranslation = translation.length > 220;
|
||||
final arabicFontSize = isVeryLongArabic
|
||||
? 22.0
|
||||
: isLongArabic
|
||||
? 24.0
|
||||
: 28.0;
|
||||
final arabicHeight = isVeryLongArabic
|
||||
? 1.55
|
||||
: isLongArabic
|
||||
? 1.62
|
||||
: 1.75;
|
||||
final translationFontSize = isVeryLongTranslation
|
||||
? 13.0
|
||||
: isLongTranslation
|
||||
? 14.0
|
||||
: 15.0;
|
||||
final translationHeight = isVeryLongTranslation ? 1.5 : 1.6;
|
||||
final verticalPadding = isVeryLongTranslation ? 22.0 : 24.0;
|
||||
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDark
|
||||
? const [
|
||||
Color(0xFF102028),
|
||||
Color(0xFF0F1217),
|
||||
Color(0xFF16343A),
|
||||
]
|
||||
: const [
|
||||
Color(0xFFF6FBFB),
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFEAF7F7),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
AppColors.primary.withValues(alpha: isDark ? 0.24 : 0.12),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: CustomPaint(
|
||||
painter: _AyatFramePainter(isDark: isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -38,
|
||||
right: -34,
|
||||
child: Container(
|
||||
width: 116,
|
||||
height: 116,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -46,
|
||||
left: -28,
|
||||
child: Container(
|
||||
width: 132,
|
||||
height: 132,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.navActiveGold.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
verticalPadding,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bookMarked,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Ayat Hari Ini',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.3,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
reference,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark
|
||||
? Colors.white
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (arabic.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
ArabicText(
|
||||
arabic,
|
||||
baseFontSize: arabicFontSize,
|
||||
height: arabicHeight,
|
||||
textAlign: TextAlign.right,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
],
|
||||
if (translation.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? Colors.white : AppColors.primary)
|
||||
.withValues(alpha: isDark ? 0.05 : 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: (isDark ? Colors.white : AppColors.primary)
|
||||
.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'"$translation"',
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: translationFontSize,
|
||||
height: translationHeight,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppColors.navActiveGold.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Text(
|
||||
'Jam Shalat Diary',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.navActiveGoldDeep,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Bagikan kebaikan',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AyatShareActionTile extends StatelessWidget {
|
||||
const _AyatShareActionTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.badge,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final String? badge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.12)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(child: icon),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppColors.navActiveGold.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.navActiveGoldDeep,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AyatFramePainter extends CustomPainter {
|
||||
const _AyatFramePainter({required this.isDark});
|
||||
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final outerRect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(22),
|
||||
);
|
||||
|
||||
final outerPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.6
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.navActiveGoldPale,
|
||||
AppColors.navActiveGold,
|
||||
AppColors.navActiveGoldDeep,
|
||||
],
|
||||
).createShader(Offset.zero & size);
|
||||
|
||||
final outerGlow = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 5
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6)
|
||||
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.08 : 0.05);
|
||||
|
||||
final innerBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16);
|
||||
final innerFrame = RRect.fromRectAndRadius(
|
||||
innerBounds,
|
||||
const Radius.circular(18),
|
||||
);
|
||||
final innerPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.8
|
||||
..color = (isDark ? Colors.white : AppColors.primary)
|
||||
.withValues(alpha: isDark ? 0.08 : 0.10);
|
||||
|
||||
canvas.drawRRect(outerRect, outerGlow);
|
||||
canvas.drawRRect(outerRect, outerPaint);
|
||||
canvas.drawRRect(innerFrame, innerPaint);
|
||||
|
||||
_drawMidMotif(canvas, size, top: true);
|
||||
_drawMidMotif(canvas, size, top: false);
|
||||
}
|
||||
|
||||
void _drawMidMotif(Canvas canvas, Size size, {required bool top}) {
|
||||
final y = top ? 14.0 : size.height - 14.0;
|
||||
final centerX = size.width / 2;
|
||||
|
||||
final linePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.9
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.26 : 0.22);
|
||||
final diamondPaint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = AppColors.primary.withValues(alpha: isDark ? 0.34 : 0.22);
|
||||
final diamondStroke = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.9
|
||||
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.58 : 0.48);
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(centerX - 26, y),
|
||||
Offset(centerX - 10, y),
|
||||
linePaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(centerX + 10, y),
|
||||
Offset(centerX + 26, y),
|
||||
linePaint,
|
||||
);
|
||||
|
||||
final diamondPath = Path()
|
||||
..moveTo(centerX, y - 5)
|
||||
..lineTo(centerX + 5, y)
|
||||
..lineTo(centerX, y + 5)
|
||||
..lineTo(centerX - 5, y)
|
||||
..close();
|
||||
canvas.drawPath(diamondPath, diamondPaint);
|
||||
canvas.drawPath(diamondPath, diamondStroke);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _AyatFramePainter oldDelegate) {
|
||||
return oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
214
lib/core/widgets/ayat_today_card.dart
Normal file
214
lib/core/widgets/ayat_today_card.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../data/services/muslim_api_service.dart';
|
||||
import 'arabic_text.dart';
|
||||
import 'ayat_share_sheet.dart';
|
||||
|
||||
class AyatTodayCard extends StatefulWidget {
|
||||
const AyatTodayCard({
|
||||
super.key,
|
||||
required this.headerText,
|
||||
required this.headerStyle,
|
||||
});
|
||||
|
||||
final String headerText;
|
||||
final TextStyle headerStyle;
|
||||
|
||||
@override
|
||||
State<AyatTodayCard> createState() => _AyatTodayCardState();
|
||||
}
|
||||
|
||||
class _AyatTodayCardState extends State<AyatTodayCard> {
|
||||
Map<String, dynamic>? _dailyAyat;
|
||||
Map<String, dynamic>? _activeAyat;
|
||||
bool _isLoading = true;
|
||||
bool _isRandomizing = false;
|
||||
bool _showingRandomAyat = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDailyAyat();
|
||||
}
|
||||
|
||||
Future<void> _loadDailyAyat() async {
|
||||
final ayat = await MuslimApiService.instance.getDailyAyat();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dailyAyat = ayat;
|
||||
_activeAyat = ayat;
|
||||
_showingRandomAyat = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showRandomAyat() async {
|
||||
if (_isRandomizing || _activeAyat == null) return;
|
||||
|
||||
setState(() => _isRandomizing = true);
|
||||
final randomAyat = await MuslimApiService.instance.getRandomAyat(
|
||||
excludeSurahNumber: _asInt(_activeAyat?['nomorSurah']),
|
||||
excludeAyahNumber: _asInt(_activeAyat?['nomorAyat']),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRandomizing = false;
|
||||
if (randomAyat != null) {
|
||||
_activeAyat = randomAyat;
|
||||
_showingRandomAyat = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _restoreDailyAyat() {
|
||||
if (_dailyAyat == null) return;
|
||||
setState(() {
|
||||
_activeAyat = _dailyAyat;
|
||||
_showingRandomAyat = false;
|
||||
});
|
||||
}
|
||||
|
||||
int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final backgroundColor = isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0);
|
||||
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final data = _activeAyat;
|
||||
if (data == null) return const SizedBox.shrink();
|
||||
|
||||
final secondaryColor =
|
||||
isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(widget.headerText, style: widget.headerStyle)),
|
||||
IconButton(
|
||||
icon: AppIcon(
|
||||
glyph: AppIcons.share,
|
||||
size: 18,
|
||||
color: secondaryColor,
|
||||
),
|
||||
tooltip: 'Bagikan ayat',
|
||||
onPressed: () => showAyatShareSheet(context, data),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
data['teksArab'] ?? '',
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isRandomizing ? null : _showRandomAyat,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
backgroundColor: AppColors.primary.withValues(
|
||||
alpha: isDark ? 0.16 : 0.12,
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
icon: _isRandomizing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
)
|
||||
: const AppIcon(
|
||||
glyph: AppIcons.shuffle,
|
||||
size: 16,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
label: Text(_showingRandomAyat ? 'Acak Lagi' : 'Ayat Lain'),
|
||||
),
|
||||
if (_showingRandomAyat)
|
||||
TextButton(
|
||||
onPressed: _restoreDailyAyat,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: secondaryColor,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
child: const Text('Kembali ke Hari Ini'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
|
||||
/// 5-tab bottom navigation bar per PRD §5.1.
|
||||
/// Uses Material Symbols outlined (inactive) and filled (active).
|
||||
class AppBottomNavBar extends StatelessWidget {
|
||||
/// 5-tab bottom navigation bar with luxury active treatment.
|
||||
class AppBottomNavBar extends ConsumerStatefulWidget {
|
||||
const AppBottomNavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
@@ -17,86 +22,207 @@ class AppBottomNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
ConsumerState<AppBottomNavBar> createState() => _AppBottomNavBarState();
|
||||
}
|
||||
|
||||
class _AppBottomNavBarState extends ConsumerState<AppBottomNavBar>
|
||||
with TickerProviderStateMixin {
|
||||
static const double _toggleRevealWidth = 88;
|
||||
static const double _dragThreshold = 38;
|
||||
static const double _inactiveIconSize = 27;
|
||||
static const double _activeIconSize = 22;
|
||||
static const double _navIconStrokeWidth = 1.9;
|
||||
late final AnimationController _shineController;
|
||||
late final AnimationController _revealController;
|
||||
late final Animation<double> _revealAnimation;
|
||||
bool _isThemeToggleOpen = false;
|
||||
double _dragDeltaX = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shineController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 5200),
|
||||
)..repeat();
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 340),
|
||||
);
|
||||
_revealAnimation = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _syncAnimation({required bool reducedMotion}) {
|
||||
if (reducedMotion) {
|
||||
if (_shineController.isAnimating) {
|
||||
_shineController.stop(canceled: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_shineController.isAnimating) {
|
||||
_shineController.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
void _openThemeToggle({bool withHaptics = true}) {
|
||||
if (_isThemeToggleOpen) return;
|
||||
_isThemeToggleOpen = true;
|
||||
if (withHaptics) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
_revealController.animateTo(1);
|
||||
}
|
||||
|
||||
void _closeThemeToggle({bool withHaptics = true}) {
|
||||
if (!_isThemeToggleOpen) return;
|
||||
_isThemeToggleOpen = false;
|
||||
if (withHaptics) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
_revealController.animateBack(0);
|
||||
}
|
||||
|
||||
void _handleNavTap(int index) {
|
||||
_closeThemeToggle(withHaptics: false);
|
||||
widget.onTap(index);
|
||||
}
|
||||
|
||||
void _handleHorizontalDragStart(DragStartDetails _) {
|
||||
_dragDeltaX = 0;
|
||||
}
|
||||
|
||||
void _handleHorizontalDragUpdate(DragUpdateDetails details) {
|
||||
_dragDeltaX += details.delta.dx;
|
||||
}
|
||||
|
||||
void _handleHorizontalDragEnd(DragEndDetails details) {
|
||||
final velocityX = details.primaryVelocity ?? 0;
|
||||
final shouldOpen = velocityX < -220 || _dragDeltaX <= -_dragThreshold;
|
||||
final shouldClose = velocityX > 220 || _dragDeltaX >= _dragThreshold;
|
||||
|
||||
if (shouldOpen && !_isThemeToggleOpen) {
|
||||
_openThemeToggle();
|
||||
} else if (shouldClose && _isThemeToggleOpen) {
|
||||
_closeThemeToggle();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleThemeMode(BuildContext context) async {
|
||||
final isDarkNow = Theme.of(context).brightness == Brightness.dark;
|
||||
final nextMode = isDarkNow ? ThemeMode.light : ThemeMode.dark;
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
|
||||
settings.themeModeIndex = nextMode == ThemeMode.dark ? 2 : 1;
|
||||
|
||||
if (settings.isInBox) {
|
||||
await settings.save();
|
||||
} else {
|
||||
await box.put('default', settings);
|
||||
}
|
||||
|
||||
ref.read(themeProvider.notifier).state = nextMode;
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (mounted) {
|
||||
_closeThemeToggle(withHaptics: false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shineController.dispose();
|
||||
_revealController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.maybeOf(context);
|
||||
final reducedMotion = (media?.disableAnimations ?? false) ||
|
||||
(media?.accessibleNavigation ?? false);
|
||||
_syncAnimation(reducedMotion: reducedMotion);
|
||||
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||
builder: (context, box, _) {
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final systemBottomInset =
|
||||
MediaQueryData.fromView(View.of(context)).viewPadding.bottom;
|
||||
|
||||
final simpleItems = const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.home),
|
||||
activeIcon: Icon(LucideIcons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
activeIcon: Icon(LucideIcons.calendar),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
activeIcon: Icon(LucideIcons.bookOpen),
|
||||
label: 'Tilawah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.headphones),
|
||||
activeIcon: Icon(LucideIcons.headphones),
|
||||
label: 'Murattal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.sparkles),
|
||||
activeIcon: Icon(LucideIcons.sparkles),
|
||||
label: 'Zikir',
|
||||
),
|
||||
];
|
||||
final items = isSimpleMode
|
||||
? const [
|
||||
_NavDef(AppIcons.home, 'Beranda'),
|
||||
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||
_NavDef(AppIcons.quran, "Al-Qur'an"),
|
||||
_NavDef(AppIcons.dzikir, 'Dzikir'),
|
||||
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||
]
|
||||
: const [
|
||||
_NavDef(AppIcons.home, 'Beranda'),
|
||||
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||
_NavDef(AppIcons.ibadah, 'Ibadah'),
|
||||
_NavDef(AppIcons.laporan, 'Laporan'),
|
||||
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||
];
|
||||
|
||||
final fullItems = const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.home),
|
||||
activeIcon: Icon(LucideIcons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
activeIcon: Icon(LucideIcons.calendar),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.listChecks),
|
||||
activeIcon: Icon(LucideIcons.listChecks),
|
||||
label: 'Ibadah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.barChart3),
|
||||
activeIcon: Icon(LucideIcons.barChart3),
|
||||
label: 'Laporan',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.wand2),
|
||||
activeIcon: Icon(LucideIcons.wand2),
|
||||
label: 'Alat',
|
||||
),
|
||||
];
|
||||
|
||||
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: isSimpleMode ? simpleItems : fullItems,
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||
Colors.transparent,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 6, 10, 6 + systemBottomInset),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onHorizontalDragStart: _handleHorizontalDragStart,
|
||||
onHorizontalDragUpdate: _handleHorizontalDragUpdate,
|
||||
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) {
|
||||
final reveal = _revealAnimation.value;
|
||||
return IgnorePointer(
|
||||
ignoring: reveal < 0.55,
|
||||
child: _ThemeUnderlayBoard(
|
||||
isDark: isDark,
|
||||
reveal: reveal,
|
||||
onTap: () => _toggleThemeMode(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) {
|
||||
final reveal = _revealAnimation.value;
|
||||
return Transform.translate(
|
||||
offset: Offset(-_toggleRevealWidth * reveal, 0),
|
||||
child: _MainNavBoard(
|
||||
items: items,
|
||||
currentIndex: widget.currentIndex,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
animation: _shineController,
|
||||
onTap: _handleNavTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -105,3 +231,477 @@ class AppBottomNavBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeUnderlayBoard extends StatelessWidget {
|
||||
const _ThemeUnderlayBoard({
|
||||
required this.isDark,
|
||||
required this.reveal,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isDark;
|
||||
final double reveal;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = reveal.clamp(0.0, 1.0).toDouble();
|
||||
return Container(
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: isDark ? const Color(0xFF090D14) : const Color(0xFFE6E6EA),
|
||||
border: Border.all(
|
||||
color: isDark ? const Color(0x1FFFFFFF) : const Color(0x140F172A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
width: _AppBottomNavBarState._toggleRevealWidth,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Transform.translate(
|
||||
offset: Offset((1 - t) * 10, 0),
|
||||
child: Transform.scale(
|
||||
scale: 0.94 + (t * 0.06),
|
||||
child: AppIcon(
|
||||
glyph: isDark ? AppIcons.themeSun : AppIcons.themeMoon,
|
||||
size: 24,
|
||||
color: isDark
|
||||
? AppColors.navActiveGoldPale
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavDef {
|
||||
const _NavDef(this.icon, this.label);
|
||||
|
||||
final AppIconGlyph icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class _MainNavBoard extends StatelessWidget {
|
||||
const _MainNavBoard({
|
||||
required this.items,
|
||||
required this.currentIndex,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.animation,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<_NavDef> items;
|
||||
final int currentIndex;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final Animation<double> animation;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final background =
|
||||
Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||
(isDark ? AppColors.surfaceDark : AppColors.surfaceLight);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isSelected = index == currentIndex;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => onTap(index),
|
||||
customBorder: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: isSelected
|
||||
? _AnimatedLuxuryActiveIcon(
|
||||
animation: animation,
|
||||
icon: item.icon,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
iconSize: _AppBottomNavBarState._activeIconSize,
|
||||
)
|
||||
: _InactiveNavIcon(
|
||||
glyph: item.icon,
|
||||
isDark: isDark,
|
||||
iconSize: _AppBottomNavBarState._inactiveIconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InactiveNavIcon extends StatelessWidget {
|
||||
const _InactiveNavIcon({
|
||||
required this.glyph,
|
||||
required this.isDark,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final AppIconGlyph glyph;
|
||||
final bool isDark;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: glyph,
|
||||
size: iconSize,
|
||||
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLuxuryActiveIcon extends StatelessWidget {
|
||||
const _AnimatedLuxuryActiveIcon({
|
||||
required this.animation,
|
||||
required this.icon,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final AppIconGlyph icon;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (_, __) {
|
||||
return _LuxuryActiveIcon(
|
||||
icon: icon,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
progress: reducedMotion ? 0.17 : animation.value,
|
||||
iconSize: iconSize,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LuxuryActiveIcon extends StatelessWidget {
|
||||
const _LuxuryActiveIcon({
|
||||
required this.icon,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.progress,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final AppIconGlyph icon;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final double progress;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseShadow = isDark
|
||||
? <BoxShadow>[
|
||||
const BoxShadow(
|
||||
color: Color(0xB3000000),
|
||||
blurRadius: 9,
|
||||
spreadRadius: 0.2,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: AppColors.navGlowDark,
|
||||
blurRadius: 8,
|
||||
spreadRadius: -0.8,
|
||||
offset: Offset(0, 1.5),
|
||||
),
|
||||
]
|
||||
: <BoxShadow>[
|
||||
const BoxShadow(
|
||||
color: AppColors.navEmbossHighlight,
|
||||
blurRadius: 3.2,
|
||||
offset: Offset(-1.1, -1.1),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: AppColors.navEmbossShadow,
|
||||
blurRadius: 7,
|
||||
offset: Offset(2.3, 3.1),
|
||||
),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF11161F) : const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: isDark ? const Color(0x383D4C61) : const Color(0x2610172A),
|
||||
width: 0.85,
|
||||
),
|
||||
boxShadow: baseShadow,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF1A202A)
|
||||
: AppColors.navActiveSurfaceLight,
|
||||
borderRadius: BorderRadius.circular(12.5),
|
||||
border: Border.all(
|
||||
color:
|
||||
isDark ? const Color(0x2EFFFFFF) : const Color(0x1F0F172A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: _LuxuryRingPainter(
|
||||
progress: progress,
|
||||
reducedMotion: reducedMotion,
|
||||
isDark: isDark,
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: icon,
|
||||
size: iconSize,
|
||||
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LuxuryRingPainter extends CustomPainter {
|
||||
const _LuxuryRingPainter({
|
||||
required this.progress,
|
||||
required this.reducedMotion,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final bool reducedMotion;
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
final outerRect = rect.deflate(1.05);
|
||||
final ringRect = rect.deflate(2.5);
|
||||
final innerRect = rect.deflate(4.75);
|
||||
|
||||
final outerRRect =
|
||||
RRect.fromRectAndRadius(outerRect, const Radius.circular(12.4));
|
||||
final ringRRect =
|
||||
RRect.fromRectAndRadius(ringRect, const Radius.circular(10.8));
|
||||
final innerRRect =
|
||||
RRect.fromRectAndRadius(innerRect, const Radius.circular(9.1));
|
||||
final rotation = reducedMotion ? math.pi * 0.63 : progress * math.pi * 2;
|
||||
|
||||
void drawEmboss(
|
||||
RRect target, {
|
||||
required Offset shadowOffset,
|
||||
required Offset highlightOffset,
|
||||
required Color shadowColor,
|
||||
required Color highlightColor,
|
||||
required double shadowBlur,
|
||||
required double highlightBlur,
|
||||
required double strokeWidth,
|
||||
}) {
|
||||
final shadowPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = shadowColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
|
||||
|
||||
final highlightPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = highlightColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur);
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(shadowOffset.dx, shadowOffset.dy);
|
||||
canvas.drawRRect(target, shadowPaint);
|
||||
canvas.restore();
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(highlightOffset.dx, highlightOffset.dy);
|
||||
canvas.drawRRect(target, highlightPaint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
drawEmboss(
|
||||
outerRRect,
|
||||
shadowOffset: const Offset(0.7, 1.0),
|
||||
highlightOffset: const Offset(-0.6, -0.7),
|
||||
shadowColor: isDark
|
||||
? const Color(0xD6000000)
|
||||
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
|
||||
highlightColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.22)
|
||||
: Colors.white.withValues(alpha: 0.92),
|
||||
shadowBlur: isDark ? 2.5 : 1.8,
|
||||
highlightBlur: isDark ? 1.6 : 1.1,
|
||||
strokeWidth: 1.05,
|
||||
);
|
||||
|
||||
drawEmboss(
|
||||
innerRRect,
|
||||
shadowOffset: const Offset(-0.45, -0.35),
|
||||
highlightOffset: const Offset(0.45, 0.55),
|
||||
shadowColor: isDark
|
||||
? Colors.black.withValues(alpha: 0.58)
|
||||
: AppColors.navEmbossShadow.withValues(alpha: 0.58),
|
||||
highlightColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.14)
|
||||
: Colors.white.withValues(alpha: 0.78),
|
||||
shadowBlur: isDark ? 1.5 : 1.2,
|
||||
highlightBlur: isDark ? 1.1 : 0.9,
|
||||
strokeWidth: 0.88,
|
||||
);
|
||||
|
||||
final metallicRing = SweepGradient(
|
||||
startAngle: rotation,
|
||||
endAngle: rotation + math.pi * 2,
|
||||
colors: const [
|
||||
AppColors.navActiveGoldDeep,
|
||||
AppColors.navActiveGold,
|
||||
AppColors.navActiveGoldBright,
|
||||
AppColors.navActiveGoldPale,
|
||||
AppColors.navActiveGoldBright,
|
||||
AppColors.navActiveGoldDeep,
|
||||
],
|
||||
stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0],
|
||||
);
|
||||
|
||||
final metallicPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.9
|
||||
..shader = metallicRing.createShader(ringRect)
|
||||
..isAntiAlias = true;
|
||||
|
||||
final chromaStrength = isDark ? 0.92 : 0.74;
|
||||
final chromaSweep = SweepGradient(
|
||||
startAngle: (rotation * 1.3) + 0.42,
|
||||
endAngle: (rotation * 1.3) + 0.42 + math.pi * 2,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
|
||||
Colors.white.withValues(alpha: 0.94 * chromaStrength),
|
||||
AppColors.navActiveGoldPale.withValues(alpha: 0.68 * chromaStrength),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
|
||||
Colors.white.withValues(alpha: 0.88 * chromaStrength),
|
||||
AppColors.navActiveGoldPale.withValues(alpha: 0.64 * chromaStrength),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [
|
||||
0.0,
|
||||
0.09,
|
||||
0.112,
|
||||
0.126,
|
||||
0.14,
|
||||
0.175,
|
||||
0.45,
|
||||
0.468,
|
||||
0.484,
|
||||
0.5,
|
||||
0.528,
|
||||
1.0,
|
||||
],
|
||||
);
|
||||
|
||||
final chromaPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.15
|
||||
..shader = chromaSweep.createShader(ringRect)
|
||||
..blendMode = BlendMode.screen;
|
||||
|
||||
canvas.drawRRect(ringRRect, metallicPaint);
|
||||
canvas.drawRRect(ringRRect, chromaPaint);
|
||||
|
||||
final ambientGold = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = isDark ? 1.0 : 0.85
|
||||
..color = isDark
|
||||
? AppColors.navActiveGold.withValues(alpha: 0.18)
|
||||
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
|
||||
..maskFilter = MaskFilter.blur(
|
||||
BlurStyle.normal,
|
||||
isDark ? 2.6 : 1.2,
|
||||
);
|
||||
canvas.drawRRect(ringRRect, ambientGold);
|
||||
|
||||
final innerEdge = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.8
|
||||
..color = isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: const Color(0x330F172A);
|
||||
canvas.drawRRect(innerRRect, innerEdge);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LuxuryRingPainter oldDelegate) {
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.reducedMotion != reducedMotion ||
|
||||
oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
|
||||
166
lib/core/widgets/notification_bell_button.dart
Normal file
166
lib/core/widgets/notification_bell_button.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
import '../../data/services/notification_service.dart';
|
||||
import '../../data/services/notification_inbox_service.dart';
|
||||
import '../../features/dashboard/data/prayer_times_provider.dart';
|
||||
|
||||
class NotificationBellButton extends StatelessWidget {
|
||||
const NotificationBellButton({
|
||||
super.key,
|
||||
this.iconColor,
|
||||
this.iconSize = 22,
|
||||
this.onPressed,
|
||||
this.showBadge = true,
|
||||
});
|
||||
|
||||
final Color? iconColor;
|
||||
final double iconSize;
|
||||
final VoidCallback? onPressed;
|
||||
final bool showBadge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inbox = NotificationInboxService.instance;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: inbox.listenable(),
|
||||
builder: (context, _, __) {
|
||||
final unread = showBadge ? inbox.unreadCount() : 0;
|
||||
return IconButton(
|
||||
onPressed: onPressed ??
|
||||
() {
|
||||
context.push('/notifications');
|
||||
},
|
||||
onLongPress: () => _showQuickActions(context),
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
if (unread > 0)
|
||||
Positioned(
|
||||
right: -6,
|
||||
top: -4,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
width: 1.4,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
unread > 99 ? '99+' : '$unread',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showQuickActions(BuildContext context) async {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
final alarmsOn = settings.adhanEnabled.values.any((v) => v);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor:
|
||||
isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark.withValues(alpha: 0.4)
|
||||
: AppColors.textSecondaryLight.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
alarmsOn
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_active_outlined,
|
||||
),
|
||||
title: Text(alarmsOn
|
||||
? 'Nonaktifkan Alarm Sholat'
|
||||
: 'Aktifkan Alarm Sholat'),
|
||||
onTap: () async {
|
||||
final container =
|
||||
ProviderScope.containerOf(context, listen: false);
|
||||
settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
|
||||
await settings.save();
|
||||
if (alarmsOn) {
|
||||
await NotificationService.instance.cancelAllPending();
|
||||
}
|
||||
container.invalidate(prayerTimesProvider);
|
||||
unawaited(container.read(prayerTimesProvider.future));
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync_rounded),
|
||||
title: const Text('Sinkronkan Sekarang'),
|
||||
onTap: () {
|
||||
final container =
|
||||
ProviderScope.containerOf(context, listen: false);
|
||||
container.invalidate(prayerTimesProvider);
|
||||
unawaited(container.read(prayerTimesProvider.future));
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: const Text('Buka Pengaturan'),
|
||||
onTap: () {
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
context.push('/settings');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
class ToolCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final AppIconGlyph icon;
|
||||
final String title;
|
||||
final Color color;
|
||||
final bool isDark;
|
||||
@@ -28,9 +29,7 @@ class ToolCard extends StatelessWidget {
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? color.withValues(alpha: 0.15)
|
||||
: AppColors.cream,
|
||||
color: isDark ? color.withValues(alpha: 0.15) : AppColors.cream,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -51,7 +50,14 @@ class ToolCard extends StatelessWidget {
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: AppIcon(
|
||||
glyph: icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
|
||||
@@ -21,6 +21,8 @@ class HiveBoxes {
|
||||
static const String dzikirCounters = 'dzikir_counters';
|
||||
static const String bookmarks = 'bookmarks';
|
||||
static const String cachedPrayerTimes = 'cached_prayer_times';
|
||||
static const String notificationInbox = 'notification_inbox';
|
||||
static const String notificationRuntime = 'notification_runtime';
|
||||
}
|
||||
|
||||
/// Initialize Hive and open all boxes.
|
||||
@@ -56,6 +58,8 @@ Future<void> initHive() async {
|
||||
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
|
||||
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
|
||||
await Hive.openBox(HiveBoxes.notificationInbox);
|
||||
await Hive.openBox(HiveBoxes.notificationRuntime);
|
||||
|
||||
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
|
||||
final keysToDelete = [];
|
||||
@@ -69,7 +73,7 @@ Future<void> initHive() async {
|
||||
keysToDelete.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (keysToDelete.isNotEmpty) {
|
||||
await worshipBox.deleteAll(keysToDelete);
|
||||
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
|
||||
@@ -89,26 +93,53 @@ Future<void> seedDefaults() async {
|
||||
if (checklistBox.isEmpty) {
|
||||
final defaults = [
|
||||
ChecklistItem(
|
||||
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
|
||||
id: 'fajr',
|
||||
title: 'Sholat Fajr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 0),
|
||||
ChecklistItem(
|
||||
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
|
||||
id: 'dhuhr',
|
||||
title: 'Sholat Dhuhr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 1),
|
||||
ChecklistItem(
|
||||
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
|
||||
id: 'asr',
|
||||
title: 'Sholat Asr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 2),
|
||||
ChecklistItem(
|
||||
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
|
||||
id: 'maghrib',
|
||||
title: 'Sholat Maghrib',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 3),
|
||||
ChecklistItem(
|
||||
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
|
||||
id: 'isha',
|
||||
title: 'Sholat Isha',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 4),
|
||||
ChecklistItem(
|
||||
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
|
||||
subtitle: '1 Juz', sortOrder: 5),
|
||||
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),
|
||||
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),
|
||||
id: 'dzikir_petang',
|
||||
title: 'Dzikir Petang',
|
||||
category: 'dzikir',
|
||||
subtitle: '1 session',
|
||||
sortOrder: 7),
|
||||
ChecklistItem(
|
||||
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
|
||||
id: 'rawatib',
|
||||
title: 'Sholat Sunnah Rawatib',
|
||||
category: 'sunnah',
|
||||
sortOrder: 8),
|
||||
ChecklistItem(
|
||||
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
|
||||
];
|
||||
|
||||
@@ -77,6 +77,33 @@ class AppSettings extends HiveObject {
|
||||
@HiveField(23)
|
||||
bool dzikirHapticOnCount;
|
||||
|
||||
@HiveField(24)
|
||||
bool alertsEnabled;
|
||||
|
||||
@HiveField(25)
|
||||
bool inboxEnabled;
|
||||
|
||||
@HiveField(26)
|
||||
bool streakRiskEnabled;
|
||||
|
||||
@HiveField(27)
|
||||
bool dailyChecklistReminderEnabled;
|
||||
|
||||
@HiveField(28)
|
||||
bool weeklySummaryEnabled;
|
||||
|
||||
@HiveField(29)
|
||||
String quietHoursStart; // HH:mm
|
||||
|
||||
@HiveField(30)
|
||||
String quietHoursEnd; // HH:mm
|
||||
|
||||
@HiveField(31)
|
||||
int maxNonPrayerPushPerDay;
|
||||
|
||||
@HiveField(32)
|
||||
bool mirrorAdzanToInbox;
|
||||
|
||||
AppSettings({
|
||||
this.userName = 'User',
|
||||
this.userEmail = '',
|
||||
@@ -102,6 +129,15 @@ class AppSettings extends HiveObject {
|
||||
this.dzikirCounterButtonPosition = 'bottomPill',
|
||||
this.dzikirAutoAdvance = true,
|
||||
this.dzikirHapticOnCount = true,
|
||||
this.alertsEnabled = true,
|
||||
this.inboxEnabled = true,
|
||||
this.streakRiskEnabled = true,
|
||||
this.dailyChecklistReminderEnabled = false,
|
||||
this.weeklySummaryEnabled = true,
|
||||
this.quietHoursStart = '22:00',
|
||||
this.quietHoursEnd = '05:00',
|
||||
this.maxNonPrayerPushPerDay = 2,
|
||||
this.mirrorAdzanToInbox = false,
|
||||
}) : adhanEnabled = adhanEnabled ??
|
||||
{
|
||||
'fajr': true,
|
||||
|
||||
@@ -20,34 +20,65 @@ class AppSettingsAdapter extends TypeAdapter<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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
showTerjemahan:
|
||||
fields.containsKey(18) ? fields[18] as bool? ?? true : true,
|
||||
simpleMode: fields.containsKey(19) ? fields[19] as bool? ?? false : false,
|
||||
dzikirDisplayMode: fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
|
||||
dzikirCounterButtonPosition: fields.containsKey(21) ? fields[21] as String? ?? 'bottomPill' : 'bottomPill',
|
||||
dzikirAutoAdvance: fields.containsKey(22) ? fields[22] as bool? ?? true : true,
|
||||
dzikirHapticOnCount: fields.containsKey(23) ? fields[23] as bool? ?? true : true,
|
||||
dzikirDisplayMode:
|
||||
fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
|
||||
dzikirCounterButtonPosition: fields.containsKey(21)
|
||||
? fields[21] as String? ?? 'bottomPill'
|
||||
: 'bottomPill',
|
||||
dzikirAutoAdvance:
|
||||
fields.containsKey(22) ? fields[22] as bool? ?? true : true,
|
||||
dzikirHapticOnCount:
|
||||
fields.containsKey(23) ? fields[23] as bool? ?? true : true,
|
||||
alertsEnabled:
|
||||
fields.containsKey(24) ? fields[24] as bool? ?? true : true,
|
||||
inboxEnabled: fields.containsKey(25) ? fields[25] as bool? ?? true : true,
|
||||
streakRiskEnabled:
|
||||
fields.containsKey(26) ? fields[26] as bool? ?? true : true,
|
||||
dailyChecklistReminderEnabled:
|
||||
fields.containsKey(27) ? fields[27] as bool? ?? false : false,
|
||||
weeklySummaryEnabled:
|
||||
fields.containsKey(28) ? fields[28] as bool? ?? true : true,
|
||||
quietHoursStart:
|
||||
fields.containsKey(29) ? fields[29] as String? ?? '22:00' : '22:00',
|
||||
quietHoursEnd:
|
||||
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
|
||||
maxNonPrayerPushPerDay:
|
||||
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
|
||||
mirrorAdzanToInbox:
|
||||
fields.containsKey(32) ? fields[32] as bool? ?? false : false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(24)
|
||||
..writeByte(33)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
@@ -95,7 +126,25 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(22)
|
||||
..write(obj.dzikirAutoAdvance)
|
||||
..writeByte(23)
|
||||
..write(obj.dzikirHapticOnCount);
|
||||
..write(obj.dzikirHapticOnCount)
|
||||
..writeByte(24)
|
||||
..write(obj.alertsEnabled)
|
||||
..writeByte(25)
|
||||
..write(obj.inboxEnabled)
|
||||
..writeByte(26)
|
||||
..write(obj.streakRiskEnabled)
|
||||
..writeByte(27)
|
||||
..write(obj.dailyChecklistReminderEnabled)
|
||||
..writeByte(28)
|
||||
..write(obj.weeklySummaryEnabled)
|
||||
..writeByte(29)
|
||||
..write(obj.quietHoursStart)
|
||||
..writeByte(30)
|
||||
..write(obj.quietHoursEnd)
|
||||
..writeByte(31)
|
||||
..write(obj.maxNonPrayerPushPerDay)
|
||||
..writeByte(32)
|
||||
..write(obj.mirrorAdzanToInbox);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class MuslimApiException implements Exception {
|
||||
@@ -138,7 +139,7 @@ class MuslimApiService {
|
||||
}
|
||||
|
||||
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
|
||||
final audioUrl = _asString(audioValue);
|
||||
final audioUrl = _extractAudioUrl(audioValue);
|
||||
if (audioUrl.isEmpty) return {};
|
||||
return {
|
||||
'01': audioUrl,
|
||||
@@ -150,6 +151,59 @@ class MuslimApiService {
|
||||
};
|
||||
}
|
||||
|
||||
String _extractAudioUrl(dynamic value) {
|
||||
if (value == null) return '';
|
||||
if (value is String) return value.trim();
|
||||
if (value is Map) {
|
||||
final direct = _asString(value['url']).trim();
|
||||
if (direct.isNotEmpty) return direct;
|
||||
final src = _asString(value['src']).trim();
|
||||
if (src.isNotEmpty) return src;
|
||||
final audio = _asString(value['audio']).trim();
|
||||
if (audio.isNotEmpty) return audio;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _normalizeQariKey(dynamic rawKey) {
|
||||
if (rawKey == null) return '';
|
||||
if (rawKey is int) return rawKey.toString().padLeft(2, '0');
|
||||
if (rawKey is num) return rawKey.toInt().toString().padLeft(2, '0');
|
||||
|
||||
final text = rawKey.toString().trim();
|
||||
if (text.isEmpty) return '';
|
||||
|
||||
final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.isNotEmpty) {
|
||||
final parsed = int.tryParse(digits);
|
||||
if (parsed != null) return parsed.toString().padLeft(2, '0');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
Map<String, String> _normalizeAyahAudioMap(dynamic audioValue) {
|
||||
if (audioValue is Map) {
|
||||
final normalized = <String, String>{};
|
||||
audioValue.forEach((rawKey, rawValue) {
|
||||
final key = _normalizeQariKey(rawKey);
|
||||
final url = _extractAudioUrl(rawValue);
|
||||
if (key.isNotEmpty && url.isNotEmpty) {
|
||||
normalized[key] = url;
|
||||
}
|
||||
});
|
||||
|
||||
if (normalized.isNotEmpty) {
|
||||
final fallbackUrl = normalized.values.first;
|
||||
for (final qariId in qariNames.keys) {
|
||||
normalized.putIfAbsent(qariId, () => fallbackUrl);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return _normalizeAudioMap(audioValue);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) {
|
||||
final number = _asInt(item['number']);
|
||||
return {
|
||||
@@ -165,20 +219,12 @@ class MuslimApiService {
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
|
||||
final audio = _asString(item['audio']);
|
||||
return {
|
||||
'nomorAyat': _asInt(item['ayah']),
|
||||
'teksArab': _asString(item['arab']),
|
||||
'teksLatin': _asString(item['latin']),
|
||||
'teksIndonesia': _asString(item['text']),
|
||||
'audio': {
|
||||
'01': audio,
|
||||
'02': audio,
|
||||
'03': audio,
|
||||
'04': audio,
|
||||
'05': audio,
|
||||
'06': audio,
|
||||
},
|
||||
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
|
||||
'juz': _asInt(item['juz']),
|
||||
'page': _asInt(item['page']),
|
||||
'hizb': _asInt(item['hizb']),
|
||||
@@ -194,10 +240,8 @@ class MuslimApiService {
|
||||
if (_surahListCache != null) return _surahListCache!;
|
||||
final raw = await _getData('/v1/quran/surah');
|
||||
if (raw is! List) return [];
|
||||
_surahListCache = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(_mapSurahSummary)
|
||||
.toList();
|
||||
_surahListCache =
|
||||
raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
|
||||
return _surahListCache!;
|
||||
}
|
||||
|
||||
@@ -219,10 +263,8 @@ class MuslimApiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mappedAyah = rawAyah
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(_mapAyah)
|
||||
.toList();
|
||||
final mappedAyah =
|
||||
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
|
||||
|
||||
final mapped = {
|
||||
...summary,
|
||||
@@ -257,11 +299,58 @@ class MuslimApiService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getWordByWord(int surahId, int ayahId) async {
|
||||
Future<Map<String, dynamic>?> getRandomAyat({
|
||||
int? excludeSurahNumber,
|
||||
int? excludeAyahNumber,
|
||||
}) async {
|
||||
try {
|
||||
final allAyah = await getAllAyah();
|
||||
if (allAyah.isEmpty) return null;
|
||||
|
||||
final surahs = await getAllSurahs();
|
||||
if (surahs.isEmpty) return null;
|
||||
|
||||
final surahNames = <int, String>{
|
||||
for (final surah in surahs)
|
||||
_asInt(surah['nomor']): _asString(surah['namaLatin']),
|
||||
};
|
||||
|
||||
final filtered = allAyah.where((ayah) {
|
||||
final surahNumber = _asInt(ayah['surah']);
|
||||
final ayahNumber = _asInt(ayah['ayah']);
|
||||
final isExcluded = excludeSurahNumber != null &&
|
||||
excludeAyahNumber != null &&
|
||||
surahNumber == excludeSurahNumber &&
|
||||
ayahNumber == excludeAyahNumber;
|
||||
if (isExcluded) return false;
|
||||
|
||||
return _asString(ayah['arab']).trim().isNotEmpty &&
|
||||
_asString(ayah['text']).trim().isNotEmpty;
|
||||
}).toList();
|
||||
|
||||
final candidates = filtered.isNotEmpty ? filtered : allAyah;
|
||||
final picked = candidates[Random().nextInt(candidates.length)];
|
||||
final surahNumber = _asInt(picked['surah']);
|
||||
|
||||
return {
|
||||
'surahName': surahNames[surahNumber] ?? '',
|
||||
'nomorSurah': surahNumber,
|
||||
'nomorAyat': _asInt(picked['ayah'], fallback: 1),
|
||||
'teksArab': _asString(picked['arab']),
|
||||
'teksIndonesia': _asString(picked['text']),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getWordByWord(
|
||||
int surahId, int ayahId) async {
|
||||
final key = '$surahId:$ayahId';
|
||||
if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!;
|
||||
|
||||
final raw = await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
|
||||
final raw =
|
||||
await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
|
||||
if (raw is! List) return [];
|
||||
|
||||
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
@@ -342,7 +431,8 @@ class MuslimApiService {
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
result.sort(
|
||||
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -386,7 +476,8 @@ class MuslimApiService {
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
result.sort(
|
||||
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -449,12 +540,17 @@ class MuslimApiService {
|
||||
if (q.isEmpty) return [];
|
||||
|
||||
final allAyah = await getAllAyah();
|
||||
final results = allAyah.where((item) {
|
||||
final text = _asString(item['text']).toLowerCase();
|
||||
final latin = _asString(item['latin']).toLowerCase();
|
||||
final arab = _asString(item['arab']);
|
||||
return text.contains(q) || latin.contains(q) || arab.contains(query.trim());
|
||||
}).take(50).toList();
|
||||
final results = allAyah
|
||||
.where((item) {
|
||||
final text = _asString(item['text']).toLowerCase();
|
||||
final latin = _asString(item['latin']).toLowerCase();
|
||||
final arab = _asString(item['arab']);
|
||||
return text.contains(q) ||
|
||||
latin.contains(q) ||
|
||||
arab.contains(query.trim());
|
||||
})
|
||||
.take(50)
|
||||
.toList();
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -478,9 +574,8 @@ class MuslimApiService {
|
||||
|
||||
Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async {
|
||||
if (_doaCache != null) return _doaCache!;
|
||||
final raw = strict
|
||||
? await _getDataOrThrow('/v1/doa')
|
||||
: await _getData('/v1/doa');
|
||||
final raw =
|
||||
strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
|
||||
if (raw is! List) {
|
||||
if (strict) {
|
||||
throw const MuslimApiException('Invalid doa payload');
|
||||
@@ -500,7 +595,8 @@ class MuslimApiService {
|
||||
return _doaCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getHaditsList({bool strict = false}) async {
|
||||
Future<List<Map<String, dynamic>>> getHaditsList(
|
||||
{bool strict = false}) async {
|
||||
if (_haditsCache != null) return _haditsCache!;
|
||||
final raw = strict
|
||||
? await _getDataOrThrow('/v1/hadits')
|
||||
|
||||
39
lib/data/services/notification_analytics_service.dart
Normal file
39
lib/data/services/notification_analytics_service.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
|
||||
/// Lightweight local analytics sink for notification events.
|
||||
class NotificationAnalyticsService {
|
||||
NotificationAnalyticsService._();
|
||||
static final NotificationAnalyticsService instance =
|
||||
NotificationAnalyticsService._();
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
|
||||
|
||||
Future<void> track(
|
||||
String event, {
|
||||
Map<String, dynamic> dimensions = const <String, dynamic>{},
|
||||
}) async {
|
||||
final date = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final counterKey = 'analytics.$date.$event';
|
||||
final current = (_box.get(counterKey) as int?) ?? 0;
|
||||
await _box.put(counterKey, current + 1);
|
||||
|
||||
// Keep a small rolling audit buffer for debug support.
|
||||
final raw = (_box.get('analytics.recent') ?? '[]').toString();
|
||||
final decoded = json.decode(raw);
|
||||
final list = decoded is List ? decoded : <dynamic>[];
|
||||
list.add({
|
||||
'event': event,
|
||||
'at': DateTime.now().toIso8601String(),
|
||||
'dimensions': dimensions,
|
||||
});
|
||||
while (list.length > 100) {
|
||||
list.removeAt(0);
|
||||
}
|
||||
await _box.put('analytics.recent', json.encode(list));
|
||||
}
|
||||
}
|
||||
299
lib/data/services/notification_event_producer_service.dart
Normal file
299
lib/data/services/notification_event_producer_service.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/app_settings.dart';
|
||||
import '../local/models/daily_worship_log.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
import 'notification_runtime_service.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Creates in-app inbox events from runtime/system conditions.
|
||||
class NotificationEventProducerService {
|
||||
NotificationEventProducerService._();
|
||||
static final NotificationEventProducerService instance =
|
||||
NotificationEventProducerService._();
|
||||
|
||||
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||
final NotificationRuntimeService _runtime =
|
||||
NotificationRuntimeService.instance;
|
||||
|
||||
Future<void> emitPermissionWarningsIfNeeded({
|
||||
required AppSettings settings,
|
||||
required NotificationPermissionStatus permissionStatus,
|
||||
}) async {
|
||||
if (!settings.adhanEnabled.values.any((v) => v)) return;
|
||||
|
||||
final dateKey = _todayKey();
|
||||
|
||||
if (!permissionStatus.notificationsAllowed) {
|
||||
final title = 'Izin notifikasi dinonaktifkan';
|
||||
final body =
|
||||
'Aktifkan izin notifikasi agar pengingat adzan dan iqamah dapat muncul.';
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/settings',
|
||||
dedupeKey: 'system.permission.notifications.$dateKey',
|
||||
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.system.permission.notifications.$dateKey',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionStatus.exactAlarmAllowed) {
|
||||
final title = 'Izin alarm presisi belum aktif';
|
||||
final body =
|
||||
'Aktifkan alarm presisi agar pengingat adzan tepat waktu di perangkat Android.';
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/settings',
|
||||
dedupeKey: 'system.permission.exact_alarm.$dateKey',
|
||||
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.system.permission.exact_alarm.$dateKey',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emitScheduleFallback({
|
||||
required AppSettings settings,
|
||||
required String cityId,
|
||||
required bool locationUnavailable,
|
||||
}) async {
|
||||
final dateKey = _todayKey();
|
||||
final title = locationUnavailable
|
||||
? 'Lokasi belum tersedia'
|
||||
: 'Jadwal online terganggu';
|
||||
final body = locationUnavailable
|
||||
? 'Lokasi perangkat belum aktif. Aplikasi menggunakan lokasi default sementara.'
|
||||
: 'Aplikasi memakai perhitungan lokal sementara. Pastikan internet aktif untuk jadwal paling akurat.';
|
||||
final scope = locationUnavailable ? 'loc' : 'net';
|
||||
final dedupe = 'system.schedule.fallback.$cityId.$dateKey.$scope';
|
||||
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/imsakiyah',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
||||
meta: <String, dynamic>{
|
||||
'cityId': cityId,
|
||||
'date': dateKey,
|
||||
'scope': scope,
|
||||
},
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> emitNotificationSyncFailed({
|
||||
required AppSettings settings,
|
||||
required String cityId,
|
||||
}) async {
|
||||
final dateKey = _todayKey();
|
||||
final title = 'Sinkronisasi alarm adzan gagal';
|
||||
final body =
|
||||
'Pengingat adzan belum tersinkron. Coba buka aplikasi lagi atau periksa pengaturan notifikasi.';
|
||||
final dedupe = 'system.notification.sync_failed.$cityId.$dateKey';
|
||||
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/settings',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
||||
meta: <String, dynamic>{
|
||||
'cityId': cityId,
|
||||
'date': dateKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> emitStreakRiskIfNeeded({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
if (!settings.inboxEnabled || !settings.streakRiskEnabled) return;
|
||||
final now = DateTime.now();
|
||||
if (now.hour < 18) return;
|
||||
|
||||
final dateKey = _todayKey();
|
||||
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = worshipBox.get(dateKey);
|
||||
if (log == null) return;
|
||||
|
||||
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
|
||||
final dzikirRisk =
|
||||
settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
|
||||
|
||||
if (tilawahRisk) {
|
||||
final title = 'Streak Tilawah berisiko terputus';
|
||||
const body =
|
||||
'Selesaikan target tilawah hari ini untuk menjaga konsistensi.';
|
||||
final dedupe = 'streak.tilawah.$dateKey';
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'streak_risk',
|
||||
source: 'local',
|
||||
deeplink: '/quran',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
||||
);
|
||||
await _pushHabitIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
if (dzikirRisk) {
|
||||
final title = 'Dzikir petang belum tercatat';
|
||||
const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
|
||||
final dedupe = 'streak.dzikir.$dateKey';
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'streak_risk',
|
||||
source: 'local',
|
||||
deeplink: '/tools/dzikir',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
||||
);
|
||||
await _pushHabitIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emitWeeklySummaryIfNeeded({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
if (!settings.inboxEnabled || !settings.weeklySummaryEnabled) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (now.weekday != DateTime.monday || now.hour < 6) return;
|
||||
|
||||
final monday = now.subtract(Duration(days: now.weekday - 1));
|
||||
final weekKey = DateFormat('yyyy-MM-dd').format(monday);
|
||||
if (_runtime.lastWeeklySummaryWeekKey() == weekKey) return;
|
||||
|
||||
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
var completionDays = 0;
|
||||
var totalPoints = 0;
|
||||
|
||||
for (int i = 1; i <= 7; i++) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final key = DateFormat('yyyy-MM-dd').format(date);
|
||||
final log = worshipBox.get(key);
|
||||
if (log == null) continue;
|
||||
if (log.completionPercent >= 70) completionDays++;
|
||||
totalPoints += log.totalPoints;
|
||||
}
|
||||
|
||||
await _inbox.addItem(
|
||||
title: 'Ringkasan Ibadah Mingguan',
|
||||
body:
|
||||
'7 hari terakhir: $completionDays hari konsisten, total $totalPoints poin. Lihat detail laporan.',
|
||||
type: 'summary',
|
||||
source: 'local',
|
||||
deeplink: '/laporan',
|
||||
dedupeKey: 'summary.weekly.$weekKey',
|
||||
expiresAt: now.add(const Duration(days: 7)),
|
||||
);
|
||||
await _runtime.setLastWeeklySummaryWeekKey(weekKey);
|
||||
}
|
||||
|
||||
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
Future<void> _pushSystemIfAllowed({
|
||||
required AppSettings settings,
|
||||
required String dedupeSeed,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _pushNonPrayer(
|
||||
settings: settings,
|
||||
dedupeSeed: dedupeSeed,
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: 'system',
|
||||
silent: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pushHabitIfAllowed({
|
||||
required AppSettings settings,
|
||||
required String dedupeSeed,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _pushNonPrayer(
|
||||
settings: settings,
|
||||
dedupeSeed: dedupeSeed,
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: 'streak_risk',
|
||||
silent: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pushNonPrayer({
|
||||
required AppSettings settings,
|
||||
required String dedupeSeed,
|
||||
required String title,
|
||||
required String body,
|
||||
required String payloadType,
|
||||
required bool silent,
|
||||
}) async {
|
||||
if (!settings.alertsEnabled) return;
|
||||
final notif = NotificationService.instance;
|
||||
await notif.showNonPrayerAlert(
|
||||
settings: settings,
|
||||
id: notif.nonPrayerNotificationId(dedupeSeed),
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: payloadType,
|
||||
silent: silent,
|
||||
);
|
||||
}
|
||||
}
|
||||
299
lib/data/services/notification_inbox_service.dart
Normal file
299
lib/data/services/notification_inbox_service.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
import 'notification_analytics_service.dart';
|
||||
|
||||
class NotificationInboxItem {
|
||||
const NotificationInboxItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
required this.expiresAt,
|
||||
required this.readAt,
|
||||
required this.isPinned,
|
||||
required this.source,
|
||||
required this.deeplink,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final String type;
|
||||
final DateTime createdAt;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? readAt;
|
||||
final bool isPinned;
|
||||
final String source;
|
||||
final String? deeplink;
|
||||
final Map<String, dynamic> meta;
|
||||
|
||||
bool get isRead => readAt != null;
|
||||
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'type': type,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'expiresAt': expiresAt?.toIso8601String(),
|
||||
'readAt': readAt?.toIso8601String(),
|
||||
'isPinned': isPinned,
|
||||
'source': source,
|
||||
'deeplink': deeplink,
|
||||
'meta': meta,
|
||||
};
|
||||
|
||||
static NotificationInboxItem fromMap(Map<dynamic, dynamic> map) {
|
||||
final createdRaw = (map['createdAt'] ?? '').toString();
|
||||
final expiresRaw = (map['expiresAt'] ?? '').toString();
|
||||
final readRaw = (map['readAt'] ?? '').toString();
|
||||
final rawMeta = map['meta'];
|
||||
|
||||
return NotificationInboxItem(
|
||||
id: (map['id'] ?? '').toString(),
|
||||
title: (map['title'] ?? '').toString(),
|
||||
body: (map['body'] ?? '').toString(),
|
||||
type: (map['type'] ?? 'system').toString(),
|
||||
createdAt: DateTime.tryParse(createdRaw) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
expiresAt: expiresRaw.isEmpty ? null : DateTime.tryParse(expiresRaw),
|
||||
readAt: readRaw.isEmpty ? null : DateTime.tryParse(readRaw),
|
||||
isPinned: map['isPinned'] == true,
|
||||
source: (map['source'] ?? 'local').toString(),
|
||||
deeplink: ((map['deeplink'] ?? '').toString().trim().isEmpty)
|
||||
? null
|
||||
: (map['deeplink'] ?? '').toString(),
|
||||
meta: rawMeta is Map
|
||||
? rawMeta.map((k, v) => MapEntry(k.toString(), v))
|
||||
: const <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationInboxService {
|
||||
NotificationInboxService._();
|
||||
static final NotificationInboxService instance = NotificationInboxService._();
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationInbox);
|
||||
|
||||
ValueListenable<Box> listenable() => _box.listenable();
|
||||
|
||||
List<NotificationInboxItem> allItems({
|
||||
String filter = 'all',
|
||||
}) {
|
||||
final items = _box.values
|
||||
.whereType<Map>()
|
||||
.map((raw) => NotificationInboxItem.fromMap(raw))
|
||||
.where((item) => !item.isExpired)
|
||||
.where((item) {
|
||||
switch (filter) {
|
||||
case 'unread':
|
||||
return !item.isRead;
|
||||
case 'system':
|
||||
return item.type == 'system';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
if (a.isPinned != b.isPinned) {
|
||||
return a.isPinned ? -1 : 1;
|
||||
}
|
||||
return b.createdAt.compareTo(a.createdAt);
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
int unreadCount() => allItems().where((e) => !e.isRead).length;
|
||||
|
||||
Future<void> addItem({
|
||||
required String title,
|
||||
required String body,
|
||||
required String type,
|
||||
String source = 'local',
|
||||
String? deeplink,
|
||||
String? dedupeKey,
|
||||
DateTime? expiresAt,
|
||||
bool isPinned = false,
|
||||
Map<String, dynamic> meta = const <String, dynamic>{},
|
||||
}) async {
|
||||
final key = dedupeKey ?? _defaultKey(type, title, body);
|
||||
if (_box.containsKey(key)) {
|
||||
final existingRaw = _box.get(key);
|
||||
if (existingRaw is Map) {
|
||||
final existing = NotificationInboxItem.fromMap(existingRaw);
|
||||
await _box.put(
|
||||
key,
|
||||
existing
|
||||
.copyWith(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type,
|
||||
source: source,
|
||||
deeplink: deeplink,
|
||||
expiresAt: expiresAt ?? existing.expiresAt,
|
||||
isPinned: isPinned || existing.isPinned,
|
||||
meta: meta.isEmpty ? existing.meta : meta,
|
||||
)
|
||||
.toMap(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final item = NotificationInboxItem(
|
||||
id: key,
|
||||
title: title,
|
||||
body: body,
|
||||
type: type,
|
||||
createdAt: DateTime.now(),
|
||||
expiresAt: expiresAt,
|
||||
readAt: null,
|
||||
isPinned: isPinned,
|
||||
source: source,
|
||||
deeplink: deeplink,
|
||||
meta: meta,
|
||||
);
|
||||
await _box.put(key, item.toMap());
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_created',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': type,
|
||||
'source': source,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markRead(String id) async {
|
||||
final raw = _box.get(id);
|
||||
if (raw is! Map) return;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isRead) return;
|
||||
await _box.put(
|
||||
id,
|
||||
item.copyWith(readAt: DateTime.now()).toMap(),
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_mark_read',
|
||||
dimensions: <String, dynamic>{'event_type': item.type},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markUnread(String id) async {
|
||||
final raw = _box.get(id);
|
||||
if (raw is! Map) return;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (!item.isRead) return;
|
||||
await _box.put(
|
||||
id,
|
||||
item.copyWith(readAt: null).toMap(),
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_mark_unread',
|
||||
dimensions: <String, dynamic>{'event_type': item.type},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
final updates = <dynamic, Map<String, dynamic>>{};
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isRead) continue;
|
||||
updates[key] = item.copyWith(readAt: DateTime.now()).toMap();
|
||||
}
|
||||
if (updates.isNotEmpty) {
|
||||
await _box.putAll(updates);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
await _box.delete(id);
|
||||
}
|
||||
|
||||
Future<void> removeByType(String type) async {
|
||||
final keys = <dynamic>[];
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.type == type) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
if (keys.isNotEmpty) {
|
||||
await _box.deleteAll(keys);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> togglePinned(String id) async {
|
||||
final raw = _box.get(id);
|
||||
if (raw is! Map) return;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
await _box.put(
|
||||
id,
|
||||
item.copyWith(isPinned: !item.isPinned).toMap(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeExpired() async {
|
||||
final expiredKeys = <dynamic>[];
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isExpired) expiredKeys.add(key);
|
||||
}
|
||||
if (expiredKeys.isNotEmpty) {
|
||||
await _box.deleteAll(expiredKeys);
|
||||
}
|
||||
}
|
||||
|
||||
String _defaultKey(String type, String title, String body) {
|
||||
final seed = '$type|$title|$body';
|
||||
var hash = 17;
|
||||
for (final rune in seed.runes) {
|
||||
hash = 31 * hash + rune;
|
||||
}
|
||||
return 'inbox_${hash.abs()}';
|
||||
}
|
||||
}
|
||||
|
||||
extension on NotificationInboxItem {
|
||||
static const _readAtUnchanged = Object();
|
||||
|
||||
NotificationInboxItem copyWith({
|
||||
String? title,
|
||||
String? body,
|
||||
String? type,
|
||||
DateTime? createdAt,
|
||||
DateTime? expiresAt,
|
||||
Object? readAt = _readAtUnchanged,
|
||||
bool? isPinned,
|
||||
String? source,
|
||||
String? deeplink,
|
||||
Map<String, dynamic>? meta,
|
||||
}) {
|
||||
return NotificationInboxItem(
|
||||
id: id,
|
||||
title: title ?? this.title,
|
||||
body: body ?? this.body,
|
||||
type: type ?? this.type,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
readAt: identical(readAt, _readAtUnchanged)
|
||||
? this.readAt
|
||||
: readAt as DateTime?,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
source: source ?? this.source,
|
||||
deeplink: deeplink ?? this.deeplink,
|
||||
meta: meta ?? this.meta,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/data/services/notification_orchestrator_service.dart
Normal file
24
lib/data/services/notification_orchestrator_service.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_event_producer_service.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
import 'remote_notification_content_service.dart';
|
||||
|
||||
/// High-level coordinator for non-prayer notification flows.
|
||||
class NotificationOrchestratorService {
|
||||
NotificationOrchestratorService._();
|
||||
static final NotificationOrchestratorService instance =
|
||||
NotificationOrchestratorService._();
|
||||
|
||||
Future<void> runPassivePass({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
await NotificationInboxService.instance.removeExpired();
|
||||
await NotificationEventProducerService.instance.emitStreakRiskIfNeeded(
|
||||
settings: settings,
|
||||
);
|
||||
await NotificationEventProducerService.instance.emitWeeklySummaryIfNeeded(
|
||||
settings: settings,
|
||||
);
|
||||
await RemoteNotificationContentService.instance.sync(settings: settings);
|
||||
}
|
||||
}
|
||||
86
lib/data/services/notification_runtime_service.dart
Normal file
86
lib/data/services/notification_runtime_service.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/app_settings.dart';
|
||||
|
||||
/// Runtime persistence for notification counters and cursors.
|
||||
class NotificationRuntimeService {
|
||||
NotificationRuntimeService._();
|
||||
static final NotificationRuntimeService instance =
|
||||
NotificationRuntimeService._();
|
||||
|
||||
static const _nonPrayerCountPrefix = 'non_prayer_push_count.';
|
||||
static const _lastRemoteSyncKey = 'remote.last_sync_at';
|
||||
static const _lastWeeklySummaryKey = 'summary.last_week_key';
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
|
||||
|
||||
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
int nonPrayerPushCountToday() {
|
||||
return (_box.get('$_nonPrayerCountPrefix${_todayKey()}') as int?) ?? 0;
|
||||
}
|
||||
|
||||
Future<void> incrementNonPrayerPushCount() async {
|
||||
final key = '$_nonPrayerCountPrefix${_todayKey()}';
|
||||
final next = ((_box.get(key) as int?) ?? 0) + 1;
|
||||
await _box.put(key, next);
|
||||
}
|
||||
|
||||
bool isWithinQuietHours(AppSettings settings, {DateTime? now}) {
|
||||
final current = now ?? DateTime.now();
|
||||
final startParts = _parseHourMinute(settings.quietHoursStart);
|
||||
final endParts = _parseHourMinute(settings.quietHoursEnd);
|
||||
if (startParts == null || endParts == null) return false;
|
||||
|
||||
final currentMinutes = current.hour * 60 + current.minute;
|
||||
final startMinutes = startParts.$1 * 60 + startParts.$2;
|
||||
final endMinutes = endParts.$1 * 60 + endParts.$2;
|
||||
|
||||
if (startMinutes == endMinutes) {
|
||||
// Same value means quiet-hours disabled.
|
||||
return false;
|
||||
}
|
||||
if (startMinutes < endMinutes) {
|
||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||
}
|
||||
// Overnight interval (e.g. 22:00 -> 05:00).
|
||||
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
||||
}
|
||||
|
||||
bool canSendNonPrayerPush(AppSettings settings, {DateTime? now}) {
|
||||
if (!settings.alertsEnabled) return false;
|
||||
if (isWithinQuietHours(settings, now: now)) return false;
|
||||
return nonPrayerPushCountToday() < settings.maxNonPrayerPushPerDay;
|
||||
}
|
||||
|
||||
DateTime? lastRemoteSyncAt() {
|
||||
final raw = (_box.get(_lastRemoteSyncKey) ?? '').toString();
|
||||
if (raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
Future<void> setLastRemoteSyncAt(DateTime value) async {
|
||||
await _box.put(_lastRemoteSyncKey, value.toIso8601String());
|
||||
}
|
||||
|
||||
String? lastWeeklySummaryWeekKey() {
|
||||
final raw = (_box.get(_lastWeeklySummaryKey) ?? '').toString();
|
||||
return raw.isEmpty ? null : raw;
|
||||
}
|
||||
|
||||
Future<void> setLastWeeklySummaryWeekKey(String key) async {
|
||||
await _box.put(_lastWeeklySummaryKey, key);
|
||||
}
|
||||
|
||||
(int, int)? _parseHourMinute(String value) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim());
|
||||
if (match == null) return null;
|
||||
final hour = int.tryParse(match.group(1) ?? '');
|
||||
final minute = int.tryParse(match.group(2) ?? '');
|
||||
if (hour == null || minute == null) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return (hour, minute);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,43 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz_data;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
/// Notification service for Adhan and Iqamah notifications.
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_analytics_service.dart';
|
||||
import 'notification_runtime_service.dart';
|
||||
|
||||
class NotificationPermissionStatus {
|
||||
const NotificationPermissionStatus({
|
||||
required this.notificationsAllowed,
|
||||
required this.exactAlarmAllowed,
|
||||
});
|
||||
|
||||
final bool notificationsAllowed;
|
||||
final bool exactAlarmAllowed;
|
||||
}
|
||||
|
||||
class NotificationPendingAlert {
|
||||
const NotificationPendingAlert({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.scheduledAt,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String body;
|
||||
final DateTime? scheduledAt;
|
||||
}
|
||||
|
||||
/// Notification service for Adzan and Iqamah reminders.
|
||||
///
|
||||
/// This service owns the local notifications setup, permission requests,
|
||||
/// timezone setup, and scheduling lifecycle for prayer notifications.
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
static final NotificationService instance = NotificationService._();
|
||||
@@ -10,16 +46,100 @@ class NotificationService {
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
String? _lastSyncSignature;
|
||||
static const int _checklistReminderId = 920001;
|
||||
|
||||
/// Initialize notification channels.
|
||||
static const _adhanDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'adhan_channel',
|
||||
'Adzan Notifications',
|
||||
channelDescription: 'Pengingat waktu adzan',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const _iqamahDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'iqamah_channel',
|
||||
'Iqamah Reminders',
|
||||
channelDescription: 'Pengingat waktu iqamah',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const _habitDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'habit_channel',
|
||||
'Pengingat Ibadah Harian',
|
||||
channelDescription: 'Pengingat checklist, streak, dan kebiasaan ibadah',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const _systemDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'system_channel',
|
||||
'Peringatan Sistem',
|
||||
channelDescription: 'Peringatan status izin dan sinkronisasi jadwal',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
playSound: false,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: false,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: false,
|
||||
),
|
||||
);
|
||||
|
||||
/// Initialize plugin, permissions, and timezone once.
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
tz_data.initializeTimeZones();
|
||||
_configureLocalTimeZone();
|
||||
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const darwinSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
const settings = InitializationSettings(
|
||||
@@ -28,71 +148,509 @@ class NotificationService {
|
||||
macOS: darwinSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(settings);
|
||||
await _plugin.initialize(settings: settings);
|
||||
await _requestPermissions();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Schedule an Adhan notification at a specific time.
|
||||
Future<void> scheduleAdhan({
|
||||
void _configureLocalTimeZone() {
|
||||
final tzId = _resolveTimeZoneIdByOffset(DateTime.now().timeZoneOffset);
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation(tzId));
|
||||
} catch (_) {
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
}
|
||||
}
|
||||
|
||||
// We prioritize Indonesian zones for better prayer scheduling defaults.
|
||||
String _resolveTimeZoneIdByOffset(Duration offset) {
|
||||
switch (offset.inMinutes) {
|
||||
case 420:
|
||||
return 'Asia/Jakarta';
|
||||
case 480:
|
||||
return 'Asia/Makassar';
|
||||
case 540:
|
||||
return 'Asia/Jayapura';
|
||||
default:
|
||||
if (offset.inMinutes % 60 == 0) {
|
||||
final etcHours = -(offset.inMinutes ~/ 60);
|
||||
final sign = etcHours >= 0 ? '+' : '';
|
||||
return 'Etc/GMT$sign$etcHours';
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
await androidPlugin?.requestNotificationsPermission();
|
||||
await androidPlugin?.requestExactAlarmsPermission();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncPrayerNotifications({
|
||||
required String cityId,
|
||||
required Map<String, bool> adhanEnabled,
|
||||
required Map<String, int> iqamahOffset,
|
||||
required Map<String, Map<String, String>> schedulesByDate,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
final hasAnyEnabled = adhanEnabled.values.any((v) => v);
|
||||
if (!hasAnyEnabled) {
|
||||
await cancelAllPending();
|
||||
_lastSyncSignature = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final signature = _buildSyncSignature(
|
||||
cityId, adhanEnabled, iqamahOffset, schedulesByDate);
|
||||
if (_lastSyncSignature == signature) return;
|
||||
|
||||
await cancelAllPending();
|
||||
|
||||
final now = DateTime.now();
|
||||
final dateEntries = schedulesByDate.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
for (final dateEntry in dateEntries) {
|
||||
final date = DateTime.tryParse(dateEntry.key);
|
||||
if (date == null) continue;
|
||||
|
||||
for (final prayerKey in const [
|
||||
'subuh',
|
||||
'dzuhur',
|
||||
'ashar',
|
||||
'maghrib',
|
||||
'isya',
|
||||
]) {
|
||||
final canonicalPrayer = _canonicalPrayerKey(prayerKey);
|
||||
if (canonicalPrayer == null) continue;
|
||||
if (!(adhanEnabled[canonicalPrayer] ?? false)) continue;
|
||||
|
||||
final rawTime = (dateEntry.value[prayerKey] ?? '').trim();
|
||||
final prayerTime = _parseScheduleDateTime(date, rawTime);
|
||||
if (prayerTime == null || !prayerTime.isAfter(now)) continue;
|
||||
|
||||
await _scheduleAdhan(
|
||||
id: _notificationId(
|
||||
cityId: cityId,
|
||||
dateKey: dateEntry.key,
|
||||
prayerKey: canonicalPrayer,
|
||||
isIqamah: false,
|
||||
),
|
||||
prayerName: _localizedPrayerName(canonicalPrayer),
|
||||
time: prayerTime,
|
||||
);
|
||||
|
||||
final offsetMinutes = iqamahOffset[canonicalPrayer] ?? 0;
|
||||
if (offsetMinutes <= 0) continue;
|
||||
|
||||
final iqamahTime = prayerTime.add(Duration(minutes: offsetMinutes));
|
||||
if (!iqamahTime.isAfter(now)) continue;
|
||||
|
||||
await _scheduleIqamah(
|
||||
id: _notificationId(
|
||||
cityId: cityId,
|
||||
dateKey: dateEntry.key,
|
||||
prayerKey: canonicalPrayer,
|
||||
isIqamah: true,
|
||||
),
|
||||
prayerName: _localizedPrayerName(canonicalPrayer),
|
||||
iqamahTime: iqamahTime,
|
||||
offsetMinutes: offsetMinutes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSyncSignature = signature;
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
id: id,
|
||||
title: 'Adzan • $prayerName',
|
||||
body: 'Waktu sholat $prayerName telah masuk.',
|
||||
scheduledDate: tz.TZDateTime.from(time, tz.local),
|
||||
notificationDetails: _adhanDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
payload: 'adhan|$prayerName|${time.toIso8601String()}',
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_push_scheduled',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': 'adhan',
|
||||
'prayer': prayerName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedule an Iqamah reminder notification.
|
||||
Future<void> scheduleIqamah({
|
||||
Future<void> _scheduleIqamah({
|
||||
required int id,
|
||||
required String prayerName,
|
||||
required DateTime adhanTime,
|
||||
required DateTime iqamahTime,
|
||||
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,
|
||||
),
|
||||
),
|
||||
id: id,
|
||||
title: 'Iqamah • $prayerName',
|
||||
body: 'Iqamah $prayerName dalam $offsetMinutes menit.',
|
||||
scheduledDate: tz.TZDateTime.from(iqamahTime, tz.local),
|
||||
notificationDetails: _iqamahDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
payload: 'iqamah|$prayerName|${iqamahTime.toIso8601String()}',
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_push_scheduled',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': 'iqamah',
|
||||
'prayer': prayerName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel all pending notifications.
|
||||
Future<void> cancelAll() async {
|
||||
await _plugin.cancelAll();
|
||||
DateTime? _parseScheduleDateTime(DateTime date, String hhmm) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})').firstMatch(hhmm);
|
||||
if (match == null) return null;
|
||||
|
||||
final hour = int.tryParse(match.group(1) ?? '');
|
||||
final minute = int.tryParse(match.group(2) ?? '');
|
||||
if (hour == null || minute == null) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return DateTime(date.year, date.month, date.day, hour, minute);
|
||||
}
|
||||
|
||||
String? _canonicalPrayerKey(String scheduleKey) {
|
||||
switch (scheduleKey) {
|
||||
case 'subuh':
|
||||
case 'fajr':
|
||||
return 'fajr';
|
||||
case 'dzuhur':
|
||||
case 'dhuhr':
|
||||
return 'dhuhr';
|
||||
case 'ashar':
|
||||
case 'asr':
|
||||
return 'asr';
|
||||
case 'maghrib':
|
||||
return 'maghrib';
|
||||
case 'isya':
|
||||
case 'isha':
|
||||
return 'isha';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedPrayerName(String canonicalPrayerKey) {
|
||||
switch (canonicalPrayerKey) {
|
||||
case 'fajr':
|
||||
return 'Subuh';
|
||||
case 'dhuhr':
|
||||
return 'Dzuhur';
|
||||
case 'asr':
|
||||
return 'Ashar';
|
||||
case 'maghrib':
|
||||
return 'Maghrib';
|
||||
case 'isha':
|
||||
return 'Isya';
|
||||
default:
|
||||
return canonicalPrayerKey;
|
||||
}
|
||||
}
|
||||
|
||||
int _notificationId({
|
||||
required String cityId,
|
||||
required String dateKey,
|
||||
required String prayerKey,
|
||||
required bool isIqamah,
|
||||
}) {
|
||||
final seed = '$cityId|$dateKey|$prayerKey|${isIqamah ? 'iqamah' : 'adhan'}';
|
||||
var hash = 17;
|
||||
for (final rune in seed.runes) {
|
||||
hash = 37 * hash + rune;
|
||||
}
|
||||
final bounded = hash.abs() % 700000;
|
||||
return isIqamah ? bounded + 800000 : bounded + 100000;
|
||||
}
|
||||
|
||||
String _buildSyncSignature(
|
||||
String cityId,
|
||||
Map<String, bool> adhanEnabled,
|
||||
Map<String, int> iqamahOffset,
|
||||
Map<String, Map<String, String>> schedulesByDate,
|
||||
) {
|
||||
final sortedAdhan = adhanEnabled.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
final sortedIqamah = iqamahOffset.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
final sortedDates = schedulesByDate.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
final buffer = StringBuffer(cityId);
|
||||
for (final e in sortedAdhan) {
|
||||
buffer.write('|${e.key}:${e.value ? 1 : 0}');
|
||||
}
|
||||
for (final e in sortedIqamah) {
|
||||
buffer.write('|${e.key}:${e.value}');
|
||||
}
|
||||
for (final dateEntry in sortedDates) {
|
||||
buffer.write('|${dateEntry.key}');
|
||||
final times = dateEntry.value.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
for (final t in times) {
|
||||
buffer.write('|${t.key}:${t.value}');
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<void> cancelAllPending() async {
|
||||
try {
|
||||
await _plugin.cancelAllPendingNotifications();
|
||||
} catch (_) {
|
||||
await _plugin.cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> pendingCount() async {
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
return pending.length;
|
||||
}
|
||||
|
||||
Future<void> syncHabitNotifications({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
if (!settings.alertsEnabled || !settings.dailyChecklistReminderEnabled) {
|
||||
await cancelChecklistReminder();
|
||||
return;
|
||||
}
|
||||
|
||||
final reminderTime = settings.checklistReminderTime ?? '09:00';
|
||||
final parts = _parseHourMinute(reminderTime);
|
||||
if (parts == null) {
|
||||
await cancelChecklistReminder();
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
var target = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
parts.$1,
|
||||
parts.$2,
|
||||
);
|
||||
if (!target.isAfter(now)) {
|
||||
target = target.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
id: _checklistReminderId,
|
||||
title: 'Checklist Ibadah Harian',
|
||||
body: 'Jangan lupa perbarui progres ibadah hari ini.',
|
||||
scheduledDate: tz.TZDateTime.from(target, tz.local),
|
||||
notificationDetails: _habitDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: 'checklist|daily|${target.toIso8601String()}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelChecklistReminder() async {
|
||||
await _plugin.cancel(id: _checklistReminderId);
|
||||
}
|
||||
|
||||
int nonPrayerNotificationId(String seed) {
|
||||
var hash = 17;
|
||||
for (final rune in seed.runes) {
|
||||
hash = 41 * hash + rune;
|
||||
}
|
||||
return 900000 + (hash.abs() % 80000);
|
||||
}
|
||||
|
||||
Future<bool> showNonPrayerAlert({
|
||||
required AppSettings settings,
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
String payloadType = 'system',
|
||||
bool silent = false,
|
||||
bool bypassQuietHours = false,
|
||||
bool bypassDailyCap = false,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
final runtime = NotificationRuntimeService.instance;
|
||||
if (!settings.alertsEnabled) return false;
|
||||
if (!bypassQuietHours && runtime.isWithinQuietHours(settings)) return false;
|
||||
if (!bypassDailyCap &&
|
||||
runtime.nonPrayerPushCountToday() >= settings.maxNonPrayerPushPerDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: silent ? _systemDetails : _habitDetails,
|
||||
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}',
|
||||
);
|
||||
|
||||
if (!bypassDailyCap) {
|
||||
await runtime.incrementNonPrayerPushCount();
|
||||
}
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_push_fired',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': payloadType,
|
||||
'channel': 'push',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<NotificationPermissionStatus> getPermissionStatus() async {
|
||||
await init();
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final notificationsAllowed =
|
||||
await androidPlugin?.areNotificationsEnabled() ?? true;
|
||||
final exactAlarmAllowed =
|
||||
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||
return NotificationPermissionStatus(
|
||||
notificationsAllowed: notificationsAllowed,
|
||||
exactAlarmAllowed: exactAlarmAllowed,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final iosPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
final options = await iosPlugin?.checkPermissions();
|
||||
return NotificationPermissionStatus(
|
||||
notificationsAllowed: options?.isEnabled ?? true,
|
||||
exactAlarmAllowed: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final macPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin>();
|
||||
final options = await macPlugin?.checkPermissions();
|
||||
return NotificationPermissionStatus(
|
||||
notificationsAllowed: options?.isEnabled ?? true,
|
||||
exactAlarmAllowed: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback to non-blocking defaults if platform query fails.
|
||||
}
|
||||
|
||||
return const NotificationPermissionStatus(
|
||||
notificationsAllowed: true,
|
||||
exactAlarmAllowed: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<NotificationPendingAlert>> pendingAlerts() async {
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
final alerts = pending.map(_mapPendingRequest).toList()
|
||||
..sort((a, b) {
|
||||
final aTime = a.scheduledAt;
|
||||
final bTime = b.scheduledAt;
|
||||
if (aTime == null && bTime == null) return a.id.compareTo(b.id);
|
||||
if (aTime == null) return 1;
|
||||
if (bTime == null) return -1;
|
||||
return aTime.compareTo(bTime);
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
NotificationPendingAlert _mapPendingRequest(PendingNotificationRequest raw) {
|
||||
final payload = raw.payload ?? '';
|
||||
final parts = payload.split('|');
|
||||
if (parts.length >= 3) {
|
||||
final type = parts[0].trim().toLowerCase();
|
||||
final title = raw.title ?? '${_labelForType(type)} • ${parts[1].trim()}';
|
||||
final body = raw.body ?? '';
|
||||
final scheduledAt = DateTime.tryParse(parts[2].trim());
|
||||
return NotificationPendingAlert(
|
||||
id: raw.id,
|
||||
type: type,
|
||||
title: title,
|
||||
body: body,
|
||||
scheduledAt: scheduledAt,
|
||||
);
|
||||
}
|
||||
|
||||
final fallbackType = _inferTypeFromTitle(raw.title ?? '');
|
||||
return NotificationPendingAlert(
|
||||
id: raw.id,
|
||||
type: fallbackType,
|
||||
title: raw.title ?? 'Pengingat',
|
||||
body: raw.body ?? '',
|
||||
scheduledAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _inferTypeFromTitle(String title) {
|
||||
final normalized = title.toLowerCase();
|
||||
if (normalized.contains('iqamah')) return 'iqamah';
|
||||
if (normalized.contains('adzan')) return 'adhan';
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
String _labelForType(String type) {
|
||||
switch (type) {
|
||||
case 'adhan':
|
||||
return 'Adzan';
|
||||
case 'iqamah':
|
||||
return 'Iqamah';
|
||||
case 'checklist':
|
||||
return 'Checklist';
|
||||
case 'streak_risk':
|
||||
return 'Streak';
|
||||
case 'system':
|
||||
return 'Sistem';
|
||||
default:
|
||||
return 'Pengingat';
|
||||
}
|
||||
}
|
||||
|
||||
(int, int)? _parseHourMinute(String hhmm) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(hhmm.trim());
|
||||
if (match == null) return null;
|
||||
final hour = int.tryParse(match.group(1) ?? '');
|
||||
final minute = int.tryParse(match.group(2) ?? '');
|
||||
if (hour == null || minute == null) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return (hour, minute);
|
||||
}
|
||||
}
|
||||
|
||||
104
lib/data/services/remote_notification_content_service.dart
Normal file
104
lib/data/services/remote_notification_content_service.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
import 'notification_runtime_service.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Pulls server-defined notification content and maps it to local inbox items.
|
||||
class RemoteNotificationContentService {
|
||||
RemoteNotificationContentService._();
|
||||
static final RemoteNotificationContentService instance =
|
||||
RemoteNotificationContentService._();
|
||||
|
||||
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||
final NotificationRuntimeService _runtime =
|
||||
NotificationRuntimeService.instance;
|
||||
|
||||
Future<void> sync({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
if (!settings.inboxEnabled) return;
|
||||
|
||||
final endpoint = (dotenv.env['NOTIFICATION_FEED_URL'] ?? '').trim();
|
||||
if (endpoint.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final lastSync = _runtime.lastRemoteSyncAt();
|
||||
if (lastSync != null &&
|
||||
now.difference(lastSync) < const Duration(hours: 6)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await http.get(Uri.parse(endpoint));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return;
|
||||
|
||||
final decoded = json.decode(response.body);
|
||||
final items = _extractItems(decoded);
|
||||
if (items.isEmpty) return;
|
||||
|
||||
for (final raw in items) {
|
||||
final id = (raw['id'] ?? '').toString().trim();
|
||||
final title = (raw['title'] ?? '').toString().trim();
|
||||
final body = (raw['body'] ?? '').toString().trim();
|
||||
if (id.isEmpty || title.isEmpty || body.isEmpty) continue;
|
||||
|
||||
final deeplink = (raw['deeplink'] ?? '').toString().trim();
|
||||
final type = (raw['type'] ?? 'content').toString().trim();
|
||||
final expiresAt =
|
||||
DateTime.tryParse((raw['expiresAt'] ?? '').toString().trim());
|
||||
final isPinned = raw['isPinned'] == true;
|
||||
final shouldPush = raw['push'] == true;
|
||||
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type.isEmpty ? 'content' : type,
|
||||
source: 'remote',
|
||||
deeplink: deeplink.isEmpty ? null : deeplink,
|
||||
dedupeKey: 'remote.$id',
|
||||
expiresAt: expiresAt,
|
||||
isPinned: isPinned,
|
||||
meta: <String, dynamic>{'remoteId': id},
|
||||
);
|
||||
|
||||
if (shouldPush && settings.alertsEnabled) {
|
||||
final notif = NotificationService.instance;
|
||||
await notif.showNonPrayerAlert(
|
||||
settings: settings,
|
||||
id: notif.nonPrayerNotificationId('remote.push.$id'),
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: 'content',
|
||||
silent: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _runtime.setLastRemoteSyncAt(now);
|
||||
} catch (_) {
|
||||
// Non-fatal: remote feed is optional.
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractItems(dynamic decoded) {
|
||||
if (decoded is List) {
|
||||
return decoded.whereType<Map>().map(_toStringKeyedMap).toList();
|
||||
}
|
||||
if (decoded is Map) {
|
||||
final list = decoded['items'];
|
||||
if (list is List) {
|
||||
return list.whereType<Map>().map(_toStringKeyedMap).toList();
|
||||
}
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toStringKeyedMap(Map raw) {
|
||||
return raw.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
}
|
||||
47
lib/data/services/remote_push_service.dart
Normal file
47
lib/data/services/remote_push_service.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
|
||||
/// Phase-4 bridge for future FCM/APNs wiring.
|
||||
///
|
||||
/// This app currently ships without Firebase/APNs SDK setup in source control.
|
||||
/// Once push SDK is configured, route incoming payloads to [ingestPayload].
|
||||
class RemotePushService {
|
||||
RemotePushService._();
|
||||
static final RemotePushService instance = RemotePushService._();
|
||||
|
||||
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||
|
||||
Future<void> init() async {
|
||||
// Reserved for SDK wiring (FCM/APNs token registration, topic subscription).
|
||||
}
|
||||
|
||||
Future<void> ingestPayload(
|
||||
Map<String, dynamic> payload, {
|
||||
AppSettings? settings,
|
||||
}) async {
|
||||
if (settings != null && !settings.inboxEnabled) return;
|
||||
|
||||
final id = (payload['id'] ?? payload['messageId'] ?? '').toString().trim();
|
||||
final title = (payload['title'] ?? '').toString().trim();
|
||||
final body = (payload['body'] ?? '').toString().trim();
|
||||
if (id.isEmpty || title.isEmpty || body.isEmpty) return;
|
||||
|
||||
final type = (payload['type'] ?? 'content').toString().trim();
|
||||
final deeplink = (payload['deeplink'] ?? '').toString().trim();
|
||||
final expiresAt =
|
||||
DateTime.tryParse((payload['expiresAt'] ?? '').toString().trim());
|
||||
final isPinned = payload['isPinned'] == true;
|
||||
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type.isEmpty ? 'content' : type,
|
||||
source: 'remote',
|
||||
deeplink: deeplink.isEmpty ? null : deeplink,
|
||||
dedupeKey: 'remote.push.$id',
|
||||
expiresAt: expiresAt,
|
||||
isPinned: isPinned,
|
||||
meta: <String, dynamic>{'remoteId': id},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../core/widgets/progress_bar.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
@@ -27,7 +28,13 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
late Box<AppSettings> _settingsBox;
|
||||
late AppSettings _settings;
|
||||
|
||||
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
|
||||
final List<String> _fardhuPrayers = [
|
||||
'Subuh',
|
||||
'Dzuhur',
|
||||
'Ashar',
|
||||
'Maghrib',
|
||||
'Isya'
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -45,7 +52,7 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
for (final p in _fardhuPrayers) {
|
||||
shalatLogs[p.toLowerCase()] = ShalatLog();
|
||||
}
|
||||
|
||||
|
||||
_logBox.put(
|
||||
_todayKey,
|
||||
DailyWorshipLog(
|
||||
@@ -69,7 +76,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
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.trackDzikir && log.dzikirLog == null)
|
||||
log.dzikirLog = DzikirLog();
|
||||
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
|
||||
|
||||
int total = 0;
|
||||
@@ -155,17 +163,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
Text(
|
||||
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -246,14 +253,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
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(LucideIcons.star, color: AppColors.primary, size: 14),
|
||||
const Icon(LucideIcons.star,
|
||||
color: AppColors.primary, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${log.totalPoints} pts',
|
||||
@@ -334,7 +343,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
border: Border.all(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Theme(
|
||||
@@ -347,10 +358,14 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(LucideIcons.building, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
|
||||
child: Icon(LucideIcons.building,
|
||||
size: 22,
|
||||
color: isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
title: Text(
|
||||
'Sholat $prayerName',
|
||||
@@ -362,7 +377,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
),
|
||||
),
|
||||
subtitle: log.location != null
|
||||
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
|
||||
? Text('Di ${log.location}',
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: AppColors.primary))
|
||||
: null,
|
||||
trailing: _CustomCheckbox(
|
||||
value: isCompleted,
|
||||
@@ -371,14 +388,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
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 Text('Pelaksanaan:',
|
||||
style:
|
||||
TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 16),
|
||||
_radioOption('Masjid', log, () {
|
||||
log.location = 'Masjid';
|
||||
@@ -422,7 +442,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
color: selected ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: selected ? AppColors.primary : null)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -453,7 +475,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
border: Border.all(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -467,10 +491,15 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(LucideIcons.bookOpen, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
|
||||
child: Icon(LucideIcons.bookOpen,
|
||||
size: 22,
|
||||
color:
|
||||
log.isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -482,13 +511,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
||||
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
|
||||
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),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -516,14 +549,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (log.autoSync)
|
||||
Tooltip(
|
||||
message: 'Sinkron dari Al-Quran',
|
||||
child: Icon(LucideIcons.refreshCw, size: 16, color: AppColors.primary),
|
||||
child: Icon(LucideIcons.refreshCw,
|
||||
size: 16, color: AppColors.primary),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.minusCircle, size: 20),
|
||||
@@ -536,7 +572,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.plusCircle, size: 20, color: AppColors.primary),
|
||||
icon: const Icon(LucideIcons.plusCircle,
|
||||
size: 20, color: AppColors.primary),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
log.rawAyatRead++;
|
||||
@@ -568,7 +605,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
const Text('Dzikir Harian',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -599,13 +637,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
children: [
|
||||
const Icon(LucideIcons.moonStar, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('Puasa Sunnah', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
|
||||
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))))
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
log.jenisPuasa = v;
|
||||
@@ -644,7 +686,9 @@ class _CustomCheckbox extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: value ? null : Border.all(color: Colors.grey, width: 2),
|
||||
),
|
||||
child: value ? const Icon(LucideIcons.check, size: 16, color: Colors.white) : null,
|
||||
child: value
|
||||
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/services/notification_orchestrator_service.dart';
|
||||
import '../../../data/services/notification_event_producer_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/notification_service.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/location_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
@@ -25,7 +30,8 @@ class DaySchedule {
|
||||
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}
|
||||
final Map<String, String>
|
||||
times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
|
||||
DaySchedule({
|
||||
required this.cityName,
|
||||
@@ -65,7 +71,8 @@ class DaySchedule {
|
||||
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) {
|
||||
if (prayers[i].time != '-' &&
|
||||
prayers[i].time.compareTo(currentTime) > 0) {
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -135,10 +142,10 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
// 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);
|
||||
|
||||
|
||||
final tmrwJadwal = await MyQuranSholatService.instance
|
||||
.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
if (tmrwJadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
@@ -152,37 +159,106 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
}
|
||||
|
||||
if (schedule != null) {
|
||||
unawaited(_syncAdhanNotifications(cityId, schedule));
|
||||
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 lat = position?.latitude ?? -6.2088;
|
||||
final lng = position?.longitude ?? 106.8456;
|
||||
final locationUnavailable = position == null;
|
||||
|
||||
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),
|
||||
},
|
||||
final result =
|
||||
PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
final fallbackSchedule = 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),
|
||||
},
|
||||
);
|
||||
unawaited(
|
||||
NotificationEventProducerService.instance.emitScheduleFallback(
|
||||
settings: Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
|
||||
AppSettings(),
|
||||
cityId: cityId,
|
||||
locationUnavailable: locationUnavailable,
|
||||
),
|
||||
);
|
||||
unawaited(_syncAdhanNotifications(cityId, fallbackSchedule));
|
||||
return fallbackSchedule;
|
||||
});
|
||||
|
||||
Future<void> _syncAdhanNotifications(
|
||||
String cityId, DaySchedule schedule) async {
|
||||
try {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
final adhanEnabled = settings.adhanEnabled.values.any((v) => v);
|
||||
|
||||
if (adhanEnabled) {
|
||||
final permissionStatus =
|
||||
await NotificationService.instance.getPermissionStatus();
|
||||
await NotificationEventProducerService.instance
|
||||
.emitPermissionWarningsIfNeeded(
|
||||
settings: settings,
|
||||
permissionStatus: permissionStatus,
|
||||
);
|
||||
}
|
||||
|
||||
final schedulesByDate = <String, Map<String, String>>{
|
||||
schedule.date: schedule.times,
|
||||
};
|
||||
|
||||
final baseDate = DateTime.tryParse(schedule.date);
|
||||
if (baseDate != null) {
|
||||
final nextDate = DateFormat('yyyy-MM-dd')
|
||||
.format(baseDate.add(const Duration(days: 1)));
|
||||
if (!schedulesByDate.containsKey(nextDate)) {
|
||||
final nextSchedule = await MyQuranSholatService.instance
|
||||
.getDailySchedule(cityId, nextDate);
|
||||
if (nextSchedule != null) {
|
||||
schedulesByDate[nextDate] = nextSchedule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await NotificationService.instance.syncPrayerNotifications(
|
||||
cityId: cityId,
|
||||
adhanEnabled: settings.adhanEnabled,
|
||||
iqamahOffset: settings.iqamahOffset,
|
||||
schedulesByDate: schedulesByDate,
|
||||
);
|
||||
await NotificationService.instance.syncHabitNotifications(
|
||||
settings: settings,
|
||||
);
|
||||
await NotificationOrchestratorService.instance.runPassivePass(
|
||||
settings: settings,
|
||||
);
|
||||
} catch (_) {
|
||||
// Don't block UI when scheduling notifications fails.
|
||||
unawaited(
|
||||
NotificationEventProducerService.instance.emitNotificationSyncFailed(
|
||||
settings: Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
|
||||
AppSettings(),
|
||||
cityId: cityId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||
final monthlyScheduleProvider =
|
||||
@@ -200,7 +276,7 @@ final cityNameProvider = FutureProvider<String>((ref) async {
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
|
||||
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
if (info != null) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class DoaScreen extends StatefulWidget {
|
||||
@@ -70,6 +74,62 @@ class _DoaScreenState extends State<DoaScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showArabicFontSettings() {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
if (!settings.isInBox) {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
double arabicFontSize = settings.arabicFontSize;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ukuran Font Arab'),
|
||||
Slider(
|
||||
value: arabicFontSize,
|
||||
min: 16,
|
||||
max: 40,
|
||||
divisions: 12,
|
||||
label: '${arabicFontSize.round()}pt',
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (value) {
|
||||
setModalState(() => arabicFontSize = value);
|
||||
settings.arabicFontSize = value;
|
||||
if (settings.isInBox) {
|
||||
settings.save();
|
||||
} else {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -78,128 +138,139 @@ class _DoaScreenState extends State<DoaScreen> {
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text('Kumpulan Doa'),
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadDoa,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showArabicFontSettings,
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
tooltip: 'Pengaturan tampilan',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi doa...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi doa...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredDoa.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Doa tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredDoa.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredDoa[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredDoa.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Doa tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredDoa.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredDoa[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
if ((item['source']?.toString().isNotEmpty ??
|
||||
false)) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Sumber: ${item['source']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
if ((item['source']
|
||||
?.toString()
|
||||
.isNotEmpty ??
|
||||
false)) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Sumber: ${item['source']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class HaditsScreen extends StatefulWidget {
|
||||
@@ -75,6 +79,62 @@ class _HaditsScreenState extends State<HaditsScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showArabicFontSettings() {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
if (!settings.isInBox) {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
double arabicFontSize = settings.arabicFontSize;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ukuran Font Arab'),
|
||||
Slider(
|
||||
value: arabicFontSize,
|
||||
min: 16,
|
||||
max: 40,
|
||||
divisions: 12,
|
||||
label: '${arabicFontSize.round()}pt',
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (value) {
|
||||
setModalState(() => arabicFontSize = value);
|
||||
settings.arabicFontSize = value;
|
||||
if (settings.isInBox) {
|
||||
settings.save();
|
||||
} else {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -83,140 +143,150 @@ class _HaditsScreenState extends State<HaditsScreen> {
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text("Hadits Arba'in"),
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadHadits,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showArabicFontSettings,
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
tooltip: 'Pengaturan tampilan',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi hadits...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi hadits...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredHadits.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Hadits tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredHadits.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredHadits[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredHadits.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Hadits tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${item['no'] ?? '-'}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredHadits.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredHadits[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius:
|
||||
BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${item['no'] ?? '-'}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
@@ -56,8 +57,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
|
||||
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||
final selected = _months[_selectedMonthIndex];
|
||||
final daysInMonth =
|
||||
DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final daysInMonth = DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final rows = <_DayRow>[];
|
||||
|
||||
for (int d = 1; d <= daysInMonth; d++) {
|
||||
@@ -102,7 +102,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
insetPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
@@ -123,7 +124,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
@@ -133,21 +134,23 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (val.trim().length < 3) return;
|
||||
|
||||
|
||||
if (debounce?.isActive ?? false) debounce!.cancel();
|
||||
debounce = Timer(const Duration(milliseconds: 500), () async {
|
||||
debounce =
|
||||
Timer(const Duration(milliseconds: 500), () async {
|
||||
if (!mounted) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
|
||||
|
||||
try {
|
||||
final res = await MyQuranSholatService.instance.searchCity(val.trim());
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error searching city: $e');
|
||||
debugPrint('Error searching city: $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
@@ -175,7 +178,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
const Text('Tidak ada hasil',
|
||||
style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
@@ -193,11 +197,11 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -224,9 +228,11 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final today = DateTime.now();
|
||||
|
||||
const tableBottomSpacing = 28.0;
|
||||
|
||||
final selectedMonth = _months[_selectedMonthIndex];
|
||||
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final monthArg =
|
||||
'${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final cityNameAsync = ref.watch(cityNameProvider);
|
||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||
|
||||
@@ -235,10 +241,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
title: const Text('Kalender Sholat'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -266,7 +269,9 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: isSelected
|
||||
? null
|
||||
@@ -306,7 +311,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
color:
|
||||
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
@@ -314,40 +320,40 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin,
|
||||
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,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin,
|
||||
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),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(LucideIcons.chevronDown,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
Icon(LucideIcons.chevronDown,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Table Header ──
|
||||
@@ -378,7 +384,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
data: (apiData) {
|
||||
final rows = _createRows(apiData);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
tableBottomSpacing,
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
@@ -453,7 +464,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
error: (_, __) {
|
||||
final rows = _createRows(null); // fallback
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
tableBottomSpacing,
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
@@ -536,7 +552,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
|
||||
@@ -5,11 +5,10 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/progress_bar.dart';
|
||||
import '../../../core/widgets/notification_bell_button.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/checklist_item.dart';
|
||||
|
||||
class LaporanScreen extends ConsumerStatefulWidget {
|
||||
const LaporanScreen({super.key});
|
||||
@@ -74,48 +73,60 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
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;
|
||||
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 (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;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
|
||||
if (log.puasaLog!.completed) {
|
||||
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +181,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final isSimpleMode = settingsBox.get('default')?.simpleMode ?? false;
|
||||
|
||||
@@ -180,10 +191,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
title: const Text('Riwayat Ibadah'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -204,10 +212,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
title: const Text('Laporan Kualitas Ibadah'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -253,7 +258,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
|
||||
_buildWeeklyView(
|
||||
context, isDark, weekData, avgPercent, insights),
|
||||
_buildComingSoon(context, 'Bulanan'),
|
||||
_buildComingSoon(context, 'Tahunan'),
|
||||
],
|
||||
@@ -332,57 +338,71 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
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),
|
||||
),
|
||||
),
|
||||
height: 162,
|
||||
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: [
|
||||
Text(
|
||||
'${d.value.round()}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
d.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: d.isToday
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 120 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
: 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(),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -587,9 +607,11 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.history, size: 64, color: AppColors.sage.withValues(alpha: 0.5)),
|
||||
Icon(LucideIcons.history,
|
||||
size: 64, color: AppColors.sage.withValues(alpha: 0.5)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Belum ada riwayat ibadah', style: TextStyle(color: AppColors.sage)),
|
||||
const Text('Belum ada riwayat ibadah',
|
||||
style: TextStyle(color: AppColors.sage)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -602,10 +624,11 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
itemBuilder: (context, index) {
|
||||
final log = logs[index];
|
||||
final isToday = log.date == DateFormat('yyyy-MM-dd').format(now);
|
||||
|
||||
|
||||
// Build summary text
|
||||
final List<String> finished = [];
|
||||
int fardhuCount = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
int fardhuCount =
|
||||
log.shalatLogs.values.where((l) => l.completed).length;
|
||||
if (fardhuCount > 0) finished.add('$fardhuCount Fardhu');
|
||||
if (log.tilawahLog?.isCompleted == true) finished.add('Tilawah');
|
||||
if (log.dzikirLog != null) {
|
||||
@@ -635,7 +658,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(LucideIcons.checkCircle2, color: AppColors.primary),
|
||||
child: const Icon(LucideIcons.checkCircle2,
|
||||
color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
@@ -643,7 +667,10 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isToday ? 'Hari Ini' : DateFormat('EEEE, d MMM yyyy').format(DateTime.parse(log.date)),
|
||||
isToday
|
||||
? 'Hari Ini'
|
||||
: DateFormat('EEEE, d MMM yyyy')
|
||||
.format(DateTime.parse(log.date)),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
@@ -651,10 +678,14 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
finished.isNotEmpty ? finished.join(' • ') : 'Belum ada aktivitas',
|
||||
finished.isNotEmpty
|
||||
? finished.join(' • ')
|
||||
: 'Belum ada aktivitas',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../app/icons/app_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/notification_analytics_service.dart';
|
||||
import '../../../data/services/notification_inbox_service.dart';
|
||||
import '../../../data/services/notification_service.dart';
|
||||
|
||||
class NotificationCenterScreen extends StatefulWidget {
|
||||
const NotificationCenterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationCenterScreen> createState() =>
|
||||
_NotificationCenterScreenState();
|
||||
}
|
||||
|
||||
class _NotificationCenterScreenState extends State<NotificationCenterScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
late Future<List<NotificationPendingAlert>> _alarmsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
unawaited(NotificationInboxService.instance.removeByType('prayer'));
|
||||
_alarmsFuture = NotificationService.instance.pendingAlerts();
|
||||
NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_opened',
|
||||
dimensions: const <String, dynamic>{'screen': 'notification_center'},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshAlarms() async {
|
||||
setState(() {
|
||||
_alarmsFuture = NotificationService.instance.pendingAlerts();
|
||||
});
|
||||
await _alarmsFuture;
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
await NotificationInboxService.instance.markAllRead();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Semua pesan sudah ditandai terbaca.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final inboxListenable = NotificationInboxService.instance.listenable();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const AppIcon(glyph: AppIcons.backArrow),
|
||||
),
|
||||
title: const Text('Pemberitahuan'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
ListenableBuilder(
|
||||
listenable: _tabController.animation!,
|
||||
builder: (context, _) {
|
||||
final tabIndex = _tabController.index;
|
||||
if (tabIndex == 0) {
|
||||
return IconButton(
|
||||
onPressed: _refreshAlarms,
|
||||
icon: Icon(
|
||||
Icons.refresh_rounded,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: inboxListenable,
|
||||
builder: (context, _, __) {
|
||||
final unread =
|
||||
NotificationInboxService.instance.unreadCount();
|
||||
if (unread <= 0) return const SizedBox.shrink();
|
||||
return TextButton(
|
||||
onPressed: _markAllRead,
|
||||
child: const Text('Tandai semua'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppColors.primary,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
tabs: [
|
||||
FutureBuilder<List<NotificationPendingAlert>>(
|
||||
future: _alarmsFuture,
|
||||
builder: (context, snapshot) {
|
||||
final count = snapshot.data?.length ?? 0;
|
||||
return Tab(text: count > 0 ? 'Alarm ($count)' : 'Alarm');
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: inboxListenable,
|
||||
builder: (context, _, __) {
|
||||
final unread = NotificationInboxService.instance.unreadCount();
|
||||
return Tab(text: unread > 0 ? 'Pesan ($unread)' : 'Pesan');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_AlarmTab(future: _alarmsFuture, onRefresh: _refreshAlarms),
|
||||
_InboxTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlarmTab extends StatefulWidget {
|
||||
const _AlarmTab({
|
||||
required this.future,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final Future<List<NotificationPendingAlert>> future;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
@override
|
||||
State<_AlarmTab> createState() => _AlarmTabState();
|
||||
}
|
||||
|
||||
class _AlarmTabState extends State<_AlarmTab> {
|
||||
String _alarmFilter = 'upcoming';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return FutureBuilder<List<NotificationPendingAlert>>(
|
||||
future: widget.future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final alarms = snapshot.data ?? const <NotificationPendingAlert>[];
|
||||
final now = DateTime.now();
|
||||
final upcoming = alarms
|
||||
.where((alarm) =>
|
||||
alarm.scheduledAt == null || !alarm.scheduledAt!.isBefore(now))
|
||||
.toList();
|
||||
final passed = alarms
|
||||
.where((alarm) =>
|
||||
alarm.scheduledAt != null && alarm.scheduledAt!.isBefore(now))
|
||||
.toList();
|
||||
final visible = _alarmFilter == 'past' ? passed : upcoming;
|
||||
|
||||
if (alarms.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: widget.onRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildAlarmFilters(
|
||||
isDark: isDark,
|
||||
upcomingCount: 0,
|
||||
passedCount: 0,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
size: 40,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Belum ada alarm aktif',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Alarm adzan dan iqamah akan muncul di sini saat sudah dijadwalkan.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: widget.onRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildAlarmFilters(
|
||||
isDark: isDark,
|
||||
upcomingCount: upcoming.length,
|
||||
passedCount: passed.length,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (visible.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDarkElevated
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.14)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_alarmFilter == 'upcoming'
|
||||
? 'Tidak ada alarm akan datang.'
|
||||
: 'Belum ada alarm sudah lewat.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final alarm in visible) ...[
|
||||
_buildAlarmItem(context, isDark: isDark, alarm: alarm),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlarmItem(
|
||||
BuildContext context, {
|
||||
required bool isDark,
|
||||
required NotificationPendingAlert alarm,
|
||||
}) {
|
||||
final chipColor = _chipColor(alarm.type);
|
||||
final when = _formatAlarmTime(alarm.scheduledAt);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.16)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: chipColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
size: 18,
|
||||
color: chipColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
alarm.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (alarm.body.isNotEmpty)
|
||||
Text(
|
||||
alarm.body,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_TypeBadge(
|
||||
label: _alarmLabel(alarm.type),
|
||||
color: chipColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
when,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlarmFilters({
|
||||
required bool isDark,
|
||||
required int upcomingCount,
|
||||
required int passedCount,
|
||||
}) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: upcomingCount > 0
|
||||
? 'Akan Datang ($upcomingCount)'
|
||||
: 'Akan Datang',
|
||||
selected: _alarmFilter == 'upcoming',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _alarmFilter = 'upcoming'),
|
||||
),
|
||||
_FilterChip(
|
||||
label: passedCount > 0 ? 'Sudah Lewat ($passedCount)' : 'Sudah Lewat',
|
||||
selected: _alarmFilter == 'past',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _alarmFilter = 'past'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _chipColor(String type) {
|
||||
switch (type) {
|
||||
case 'adhan':
|
||||
return AppColors.primary;
|
||||
case 'iqamah':
|
||||
return const Color(0xFF7B61FF);
|
||||
case 'checklist':
|
||||
return const Color(0xFF2D98DA);
|
||||
case 'system':
|
||||
return const Color(0xFFE17055);
|
||||
default:
|
||||
return AppColors.sage;
|
||||
}
|
||||
}
|
||||
|
||||
String _alarmLabel(String type) {
|
||||
switch (type) {
|
||||
case 'adhan':
|
||||
return 'Adzan';
|
||||
case 'iqamah':
|
||||
return 'Iqamah';
|
||||
case 'checklist':
|
||||
return 'Checklist';
|
||||
case 'system':
|
||||
return 'Sistem';
|
||||
default:
|
||||
return 'Alarm';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatAlarmTime(DateTime? value) {
|
||||
if (value == null) return 'Waktu tidak diketahui';
|
||||
try {
|
||||
return DateFormat('EEE, d MMM • HH:mm', 'id_ID').format(value);
|
||||
} catch (_) {
|
||||
return DateFormat('d/MM • HH:mm').format(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InboxTab extends StatefulWidget {
|
||||
@override
|
||||
State<_InboxTab> createState() => _InboxTabState();
|
||||
}
|
||||
|
||||
class _InboxTabState extends State<_InboxTab> {
|
||||
String _filter = 'all';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final inbox = NotificationInboxService.instance;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: inbox.listenable(),
|
||||
builder: (context, _, __) {
|
||||
final items = inbox.allItems(filter: _filter);
|
||||
if (items.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildFilters(isDark),
|
||||
const SizedBox(height: 20),
|
||||
AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
size: 40,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Belum ada pesan',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Pesan sistem dan ringkasan ibadah akan muncul di sini.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildFilters(isDark),
|
||||
const SizedBox(height: 12),
|
||||
...items.map((item) {
|
||||
final accent = _inboxAccent(item.type);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
background: _swipeBackground(
|
||||
isDark: isDark,
|
||||
icon: item.isRead ? Icons.mark_email_unread : Icons.done,
|
||||
label: item.isRead ? 'Belum dibaca' : 'Tandai dibaca',
|
||||
alignment: Alignment.centerLeft,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
secondaryBackground: _swipeBackground(
|
||||
isDark: isDark,
|
||||
icon: Icons.delete_outline,
|
||||
label: 'Hapus',
|
||||
alignment: Alignment.centerRight,
|
||||
color: AppColors.errorLight,
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
if (item.isRead) {
|
||||
await inbox.markUnread(item.id);
|
||||
} else {
|
||||
await inbox.markRead(item.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await inbox.remove(item.id);
|
||||
return true;
|
||||
},
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () async {
|
||||
if (!item.isRead) {
|
||||
await inbox.markRead(item.id);
|
||||
}
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_opened',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': item.type,
|
||||
'deeplink': item.deeplink ?? '',
|
||||
},
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
final deeplink = item.deeplink;
|
||||
if (deeplink != null && deeplink.isNotEmpty) {
|
||||
if (deeplink.startsWith('/')) {
|
||||
context.go(deeplink);
|
||||
} else {
|
||||
context.push(deeplink);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDarkElevated
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: !item.isRead
|
||||
? accent.withValues(alpha: isDark ? 0.4 : 0.32)
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.14)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: _inboxGlyph(item.type),
|
||||
size: 18,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (item.isPinned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
Icons.push_pin_rounded,
|
||||
size: 15,
|
||||
color: AppColors.navActiveGoldDeep,
|
||||
),
|
||||
),
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.body,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_TypeBadge(
|
||||
label: _inboxLabel(item.type),
|
||||
color: accent,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatInboxTime(item.createdAt),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => inbox.togglePinned(item.id),
|
||||
icon: Icon(
|
||||
item.isPinned
|
||||
? Icons.push_pin_rounded
|
||||
: Icons.push_pin_outlined,
|
||||
size: 18,
|
||||
color: item.isPinned
|
||||
? AppColors.navActiveGoldDeep
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters(bool isDark) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'Semua',
|
||||
selected: _filter == 'all',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _filter = 'all'),
|
||||
),
|
||||
_FilterChip(
|
||||
label: 'Belum Dibaca',
|
||||
selected: _filter == 'unread',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _filter = 'unread'),
|
||||
),
|
||||
_FilterChip(
|
||||
label: 'Sistem',
|
||||
selected: _filter == 'system',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _filter = 'system'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swipeBackground({
|
||||
required bool isDark,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Alignment alignment,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppIconGlyph _inboxGlyph(String type) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return AppIcons.settings;
|
||||
case 'summary':
|
||||
case 'streak_risk':
|
||||
return AppIcons.laporan;
|
||||
case 'prayer':
|
||||
return AppIcons.notification;
|
||||
case 'content':
|
||||
return AppIcons.notification;
|
||||
default:
|
||||
return AppIcons.notification;
|
||||
}
|
||||
}
|
||||
|
||||
Color _inboxAccent(String type) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return const Color(0xFFE17055);
|
||||
case 'summary':
|
||||
return const Color(0xFF7B61FF);
|
||||
case 'prayer':
|
||||
return AppColors.primary;
|
||||
case 'content':
|
||||
return const Color(0xFF00CEC9);
|
||||
default:
|
||||
return AppColors.sage;
|
||||
}
|
||||
}
|
||||
|
||||
String _inboxLabel(String type) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return 'Sistem';
|
||||
case 'summary':
|
||||
return 'Ringkasan';
|
||||
case 'streak_risk':
|
||||
return 'Pengingat';
|
||||
case 'prayer':
|
||||
return 'Sholat';
|
||||
case 'content':
|
||||
return 'Konten';
|
||||
default:
|
||||
return 'Pesan';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatInboxTime(DateTime value) {
|
||||
final now = DateTime.now();
|
||||
final isToday = now.year == value.year &&
|
||||
now.month == value.month &&
|
||||
now.day == value.day;
|
||||
try {
|
||||
if (isToday) {
|
||||
return DateFormat('HH:mm', 'id_ID').format(value);
|
||||
}
|
||||
return DateFormat('d MMM • HH:mm', 'id_ID').format(value);
|
||||
} catch (_) {
|
||||
if (isToday) {
|
||||
return DateFormat('HH:mm').format(value);
|
||||
}
|
||||
return DateFormat('d/MM • HH:mm').format(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
const _FilterChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.isDark,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool isDark;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? AppColors.primary.withValues(alpha: isDark ? 0.22 : 0.16)
|
||||
: (isDark
|
||||
? AppColors.surfaceDarkElevated
|
||||
: AppColors.surfaceLightElevated),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeBadge extends StatelessWidget {
|
||||
const _TypeBadge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,18 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/quran_bookmark.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class QuranBookmarksScreen extends StatefulWidget {
|
||||
const QuranBookmarksScreen({super.key});
|
||||
final bool isSimpleModeTab;
|
||||
const QuranBookmarksScreen({
|
||||
super.key,
|
||||
this.isSimpleModeTab = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuranBookmarksScreen> createState() => _QuranBookmarksScreenState();
|
||||
@@ -18,6 +24,8 @@ class QuranBookmarksScreen extends StatefulWidget {
|
||||
class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
bool _showLatin = true;
|
||||
bool _showTerjemahan = true;
|
||||
final Map<int, Future<Map<String, dynamic>?>> _surahFutureCache = {};
|
||||
final Map<dynamic, Future<_ResolvedBookmarkContent?>> _bookmarkFutureCache = {};
|
||||
|
||||
String _readingRoute(int surahId, int verseId) {
|
||||
final isSimple =
|
||||
@@ -39,13 +47,16 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
void _showDisplaySettings() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -90,6 +101,59 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _getSurah(int surahId) {
|
||||
return _surahFutureCache.putIfAbsent(
|
||||
surahId,
|
||||
() => MuslimApiService.instance.getSurah(surahId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ResolvedBookmarkContent?> _loadResolvedBookmarkContent(
|
||||
QuranBookmark bookmark,
|
||||
) async {
|
||||
final surah = await _getSurah(bookmark.surahId);
|
||||
final verses = List<Map<String, dynamic>>.from(surah?['ayat'] ?? []);
|
||||
final verseIndex = bookmark.verseId - 1;
|
||||
if (verseIndex < 0 || verseIndex >= verses.length) return null;
|
||||
|
||||
final verse = verses[verseIndex];
|
||||
final resolved = _ResolvedBookmarkContent(
|
||||
verseText: verse['teksArab']?.toString().trim().isNotEmpty == true
|
||||
? verse['teksArab'].toString().trim()
|
||||
: bookmark.verseText,
|
||||
verseLatin: verse['teksLatin']?.toString().trim().isNotEmpty == true
|
||||
? verse['teksLatin'].toString().trim()
|
||||
: bookmark.verseLatin,
|
||||
verseTranslation:
|
||||
verse['teksIndonesia']?.toString().trim().isNotEmpty == true
|
||||
? verse['teksIndonesia'].toString().trim()
|
||||
: bookmark.verseTranslation,
|
||||
);
|
||||
|
||||
final needsUpdate = bookmark.verseText != resolved.verseText ||
|
||||
bookmark.verseLatin != resolved.verseLatin ||
|
||||
bookmark.verseTranslation != resolved.verseTranslation;
|
||||
|
||||
if (needsUpdate) {
|
||||
bookmark.verseText = resolved.verseText;
|
||||
bookmark.verseLatin = resolved.verseLatin;
|
||||
bookmark.verseTranslation = resolved.verseTranslation;
|
||||
await bookmark.save();
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
Future<_ResolvedBookmarkContent?> _getResolvedBookmarkContent(
|
||||
QuranBookmark bookmark,
|
||||
) {
|
||||
final bookmarkKey = bookmark.key ?? '${bookmark.surahId}_${bookmark.verseId}';
|
||||
return _bookmarkFutureCache.putIfAbsent(
|
||||
bookmarkKey,
|
||||
() => _loadResolvedBookmarkContent(bookmark),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -105,94 +169,106 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
LucideIcons.bookmark,
|
||||
size: 64,
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Belum ada markah',
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: 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(
|
||||
LucideIcons.bookmark,
|
||||
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: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tandai ayat saat membaca Al-Quran',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 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}) {
|
||||
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);
|
||||
|
||||
final resolvedFuture = _getResolvedBookmarkContent(bookmark);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
onTap: () =>
|
||||
context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -200,18 +276,22 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
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),
|
||||
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,
|
||||
boxShadow: isLastRead
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -220,7 +300,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -229,7 +310,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLastRead) ...[
|
||||
const Icon(LucideIcons.pin, size: 12, color: AppColors.primary),
|
||||
const Icon(LucideIcons.pin,
|
||||
size: 12, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
@@ -244,7 +326,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 20),
|
||||
icon: const Icon(LucideIcons.trash2,
|
||||
color: Colors.red, size: 20),
|
||||
onPressed: () => box.delete(bookmark.key),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
@@ -252,76 +335,93 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
bookmark.verseText,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
FutureBuilder<_ResolvedBookmarkContent?>(
|
||||
future: resolvedFuture,
|
||||
builder: (context, snapshot) {
|
||||
final content = snapshot.data ??
|
||||
_ResolvedBookmarkContent(
|
||||
verseText: bookmark.verseText,
|
||||
verseLatin: bookmark.verseLatin,
|
||||
verseTranslation: bookmark.verseTranslation,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
content.verseText,
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
if (_showLatin && content.verseLatin != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
content.verseLatin!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_showTerjemahan &&
|
||||
content.verseTranslation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
content.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(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
onPressed: () => context
|
||||
.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
icon: const Icon(LucideIcons.bookOpen, size: 18),
|
||||
label: const Text('Lanjutkan Membaca'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.clock,
|
||||
size: 12,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
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,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -332,3 +432,15 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolvedBookmarkContent {
|
||||
const _ResolvedBookmarkContent({
|
||||
required this.verseText,
|
||||
this.verseLatin,
|
||||
this.verseTranslation,
|
||||
});
|
||||
|
||||
final String verseText;
|
||||
final String? verseLatin;
|
||||
final String? verseTranslation;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class QuranEnrichmentScreen extends StatefulWidget {
|
||||
const QuranEnrichmentScreen({super.key});
|
||||
final bool isSimpleModeTab;
|
||||
const QuranEnrichmentScreen({
|
||||
super.key,
|
||||
this.isSimpleModeTab = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuranEnrichmentScreen> createState() => _QuranEnrichmentScreenState();
|
||||
@@ -15,12 +23,12 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
late TabController _tabController;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _pageController = TextEditingController(text: '1');
|
||||
final TextEditingController _pageController =
|
||||
TextEditingController(text: '1');
|
||||
|
||||
List<Map<String, dynamic>> _surahs = [];
|
||||
List<Map<String, dynamic>> _searchResults = [];
|
||||
List<Map<String, dynamic>> _tafsirItems = [];
|
||||
List<Map<String, dynamic>> _asbabItems = [];
|
||||
List<Map<String, dynamic>> _juzItems = [];
|
||||
List<Map<String, dynamic>> _pageItems = [];
|
||||
List<Map<String, dynamic>> _themeItems = [];
|
||||
@@ -31,7 +39,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
bool _loadingInit = true;
|
||||
bool _loadingSearch = false;
|
||||
bool _loadingTafsir = false;
|
||||
bool _loadingAsbab = false;
|
||||
bool _loadingPage = false;
|
||||
String? _error;
|
||||
|
||||
@@ -42,7 +49,7 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 7, vsync: this);
|
||||
_tabController = TabController(length: 6, vsync: this);
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
@@ -69,9 +76,8 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_surahs = surahs;
|
||||
_selectedSurahId = surahs.isNotEmpty
|
||||
? ((surahs.first['nomor'] as int?) ?? 1)
|
||||
: 1;
|
||||
_selectedSurahId =
|
||||
surahs.isNotEmpty ? ((surahs.first['nomor'] as int?) ?? 1) : 1;
|
||||
_juzItems = juz;
|
||||
_themeItems = themes;
|
||||
_asmaItems = asma;
|
||||
@@ -79,7 +85,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
});
|
||||
|
||||
await _loadTafsirForSelectedSurah();
|
||||
await _loadAsbabForSelectedSurah();
|
||||
await _loadPageAyah();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
@@ -117,16 +122,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAsbabForSelectedSurah() async {
|
||||
setState(() => _loadingAsbab = true);
|
||||
final result = await MuslimApiService.instance.getAsbabBySurah(_selectedSurahId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_asbabItems = result;
|
||||
_loadingAsbab = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadPageAyah() async {
|
||||
setState(() => _loadingPage = true);
|
||||
final page = int.tryParse(_pageController.text.trim()) ?? _selectedPage;
|
||||
@@ -181,6 +176,62 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
return 'Surah $surahId';
|
||||
}
|
||||
|
||||
void _showArabicFontSettings() {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
if (!settings.isInBox) {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
double arabicFontSize = settings.arabicFontSize;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ukuran Font Arab'),
|
||||
Slider(
|
||||
value: arabicFontSize,
|
||||
min: 16,
|
||||
max: 40,
|
||||
divisions: 12,
|
||||
label: '${arabicFontSize.round()}pt',
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (value) {
|
||||
setModalState(() => arabicFontSize = value);
|
||||
settings.arabicFontSize = value;
|
||||
if (settings.isInBox) {
|
||||
settings.save();
|
||||
} else {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -188,12 +239,18 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Quran Enrichment'),
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _bootstrap,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showArabicFontSettings,
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
tooltip: 'Pengaturan tampilan',
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
@@ -206,7 +263,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
tabs: const [
|
||||
Tab(text: 'Cari'),
|
||||
Tab(text: 'Tafsir'),
|
||||
Tab(text: 'Asbab'),
|
||||
Tab(text: 'Juz'),
|
||||
Tab(text: 'Halaman'),
|
||||
Tab(text: 'Tema'),
|
||||
@@ -214,31 +270,34 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _loadingInit
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: _loadingInit
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSearchTab(context, isDark),
|
||||
_buildTafsirTab(context, isDark),
|
||||
_buildJuzTab(context, isDark),
|
||||
_buildPageTab(context, isDark),
|
||||
_buildThemeTab(context, isDark),
|
||||
_buildAsmaTab(context, isDark),
|
||||
],
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSearchTab(context, isDark),
|
||||
_buildTafsirTab(context, isDark),
|
||||
_buildAsbabTab(context, isDark),
|
||||
_buildJuzTab(context, isDark),
|
||||
_buildPageTab(context, isDark),
|
||||
_buildThemeTab(context, isDark),
|
||||
_buildAsmaTab(context, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,15 +401,12 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
child: ArabicText(
|
||||
ayah['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -396,13 +452,10 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ArabicText(
|
||||
word['arab']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
baseFontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
@@ -474,41 +527,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAsbabTab(BuildContext context, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSurahSelector(
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedSurahId = value);
|
||||
_loadAsbabForSelectedSurah();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _loadingAsbab
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _asbabItems.isEmpty
|
||||
? _emptyText(
|
||||
isDark,
|
||||
'Belum ada data asbabun nuzul untuk surah ini',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
itemCount: _asbabItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _asbabItems[index];
|
||||
final ayah = item['nomorAyat']?.toString() ?? '-';
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: 'Ayat $ayah',
|
||||
body: item['text']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJuzTab(BuildContext context, bool isDark) {
|
||||
if (_juzItems.isEmpty) {
|
||||
return _emptyText(isDark, 'Data juz tidak tersedia');
|
||||
@@ -575,11 +593,11 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
final surahId = (item['surah'] as num?)?.toInt() ?? 0;
|
||||
final ayah = item['ayah']?.toString() ?? '-';
|
||||
|
||||
return _buildCard(
|
||||
return _buildArabicCard(
|
||||
isDark,
|
||||
title: '${_surahNameById(surahId)} : $ayah',
|
||||
body:
|
||||
'${item['arab']?.toString() ?? ''}\n\n${item['text']?.toString() ?? ''}',
|
||||
arabic: item['arab']?.toString() ?? '',
|
||||
translation: item['text']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -652,13 +670,10 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ArabicText(
|
||||
item['arab']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
baseFontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
Text(
|
||||
item['latin']?.toString() ?? '',
|
||||
@@ -727,7 +742,54 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(bool isDark, {required String title, required String body}) {
|
||||
Widget _buildArabicCard(
|
||||
bool isDark, {
|
||||
required String title,
|
||||
required String arabic,
|
||||
required String translation,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
arabic,
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(translation, style: const TextStyle(height: 1.5)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(bool isDark,
|
||||
{required String title, required String body}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/quran_bookmark.dart';
|
||||
@@ -47,13 +48,16 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
void _showDisplaySettings() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -134,140 +138,148 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: 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(LucideIcons.search,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari surah...',
|
||||
prefixIcon: Icon(LucideIcons.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,
|
||||
// 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.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream,
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
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);
|
||||
),
|
||||
)
|
||||
: 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'] ?? '';
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.push(widget.isSimpleModeTab
|
||||
? '/quran/$number'
|
||||
: '/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,
|
||||
final hasLastRead = box.values.any(
|
||||
(b) => b.isLastRead && b.surahId == number);
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.push(
|
||||
widget.isSimpleModeTab
|
||||
? '/quran/$number'
|
||||
: '/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,
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
nameLatin,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasLastRead) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(LucideIcons.pin, size: 14, color: AppColors.primary),
|
||||
if (hasLastRead) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(LucideIcons.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,
|
||||
subtitle: Text(
|
||||
'$arti • $totalVerses Ayat • $tempatTurun',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
trailing: ArabicText(
|
||||
nameArabic,
|
||||
baseFontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../../app/icons/app_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/ayat_today_card.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../core/widgets/tool_card.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
class ToolsScreen extends ConsumerWidget {
|
||||
const ToolsScreen({super.key});
|
||||
@@ -12,19 +17,72 @@ class ToolsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSimpleMode =
|
||||
Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ??
|
||||
false;
|
||||
final cards = <Widget>[
|
||||
if (!isSimpleMode)
|
||||
ToolCard(
|
||||
icon: AppIcons.quran,
|
||||
title: "Al-Qur'an\nTerjemahan",
|
||||
color: const Color(0xFF00B894),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.murattal,
|
||||
title: "Qur'an\nMurattal",
|
||||
color: const Color(0xFF7B61FF),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/1/murattal'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.qibla,
|
||||
title: 'Arah\nKiblat',
|
||||
color: const Color(0xFF0984E3),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
),
|
||||
if (!isSimpleMode)
|
||||
ToolCard(
|
||||
icon: AppIcons.dzikir,
|
||||
title: 'Dzikir\nHarian',
|
||||
color: AppColors.primary,
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/dzikir'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.doa,
|
||||
title: 'Kumpulan\nDoa',
|
||||
color: const Color(0xFFE17055),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/doa'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.hadits,
|
||||
title: "Hadits\nArba'in",
|
||||
color: const Color(0xFF6C5CE7),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/hadits'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.quranEnrichment,
|
||||
title: "Pendalaman\nAl-Qur'an",
|
||||
color: const Color(0xFF00CEC9),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/enrichment'),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Alat Islami'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
icon: const AppIcon(glyph: AppIcons.settings),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -34,7 +92,7 @@ class ToolsScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
'AKSES CEPAT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -44,193 +102,37 @@ class ToolsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.bookOpen,
|
||||
title: 'Al-Quran\nTerjemahan',
|
||||
color: const Color(0xFF00B894),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.headphones,
|
||||
title: 'Quran\nMurattal',
|
||||
color: const Color(0xFF7B61FF),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/1/murattal'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.compass,
|
||||
title: 'Arah\nKiblat',
|
||||
color: const Color(0xFF0984E3),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.sparkles,
|
||||
title: 'Dzikir\nHarian',
|
||||
color: AppColors.primary,
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/dzikir'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.heart,
|
||||
title: 'Kumpulan\nDoa',
|
||||
color: const Color(0xFFE17055),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/doa'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.library,
|
||||
title: "Hadits\nArba'in",
|
||||
color: const Color(0xFF6C5CE7),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/hadits'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.sparkles,
|
||||
title: 'Quran\nEnrichment',
|
||||
color: const Color(0xFF00CEC9),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/enrichment'),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
_buildQuickActionsGrid(cards),
|
||||
const SizedBox(height: 28),
|
||||
FutureBuilder<Map<String, dynamic>?>(
|
||||
future: MuslimApiService.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();
|
||||
}
|
||||
|
||||
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(
|
||||
LucideIcons.share2,
|
||||
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,
|
||||
fontWeight: FontWeight.w400,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
AyatTodayCard(
|
||||
headerText: 'Ayat Hari Ini',
|
||||
headerStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActionsGrid(List<Widget> cards) {
|
||||
const spacing = 12.0;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardWidth = (constraints.maxWidth - spacing) / 2;
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final card in cards) SizedBox(width: cardWidth, child: card),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'data/local/hive_boxes.dart';
|
||||
import 'data/local/models/app_settings.dart';
|
||||
import 'data/services/notification_inbox_service.dart';
|
||||
import 'data/services/notification_orchestrator_service.dart';
|
||||
import 'data/services/remote_push_service.dart';
|
||||
import 'data/services/notification_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -17,6 +27,31 @@ void main() async {
|
||||
// Seed default settings and checklist items on first launch
|
||||
await seedDefaults();
|
||||
|
||||
// Ensure intl DateFormat locale data is ready before any localized formatting.
|
||||
await initializeDateFormatting('id_ID');
|
||||
|
||||
// Initialize local notifications for adzan/iqamah scheduling
|
||||
await NotificationService.instance.init();
|
||||
await RemotePushService.instance.init();
|
||||
|
||||
// Run passive notification checks at startup (inbox cleanup/content sync).
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
// Cleanup legacy mirrored prayer inbox items.
|
||||
await NotificationInboxService.instance.removeByType('prayer');
|
||||
unawaited(NotificationService.instance.syncHabitNotifications(
|
||||
settings: settings,
|
||||
));
|
||||
unawaited(NotificationOrchestratorService.instance.runPassivePass(
|
||||
settings: settings,
|
||||
));
|
||||
|
||||
await JustAudioBackground.init(
|
||||
androidNotificationChannelId: 'com.jamshalat.diary.audio',
|
||||
androidNotificationChannelName: 'Murattal Playback',
|
||||
androidNotificationOngoing: true,
|
||||
);
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: App(),
|
||||
|
||||
Reference in New Issue
Block a user