Files
jamshalat-diary/lib/app/app.dart
Dwindi Ramadhana 4badfb6521 Notification system audit: fix 6 defects, close 5 gaps, add rich notifications (v1.1.0)
Defects fixed:
- D1: Fix notification ID range collision (report reminders 700k→2M+)
- D2: Streak risk now checks both dzikir pagi & petang
- D3: _cancelPrayerPending no longer kills non-prayer notifications
- D4: Push notifications carry deeplink in payload for proper routing
- D5: Add reconfigureTimeZoneIfNeeded() for TZ change detection
- D6: Defer launch notification routing until widget tree is ready

Gaps closed:
- G1: Add streak risk + weekly summary toggles to settings UI
- G2: Verified boot reschedule already in place (flutter_local_notifications v21)
- G3: Remove unused mirrorAdzanToInbox field and legacy cleanup calls
- G4: Add notif_push_opened analytics tracking
- G5: Add notif_settings_changed analytics tracking

Enhancements:
- O1: Rich notification with Sudah Sholat action button on report reminders
- O2: Permission check on app resume via WidgetsBindingObserver (30s throttle)
- O2b: Fix stretched notification icon (white crescent moon vector drawable)
- O3: Expired inbox cleanup in background sync
- O4: Haptic feedback on notification bell quick actions

Bump version 1.0.8+9 → 1.1.0+10
2026-06-06 22:38:02 +07:00

124 lines
3.8 KiB
Dart

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 'package:hive_flutter/hive_flutter.dart';
import '../core/providers/theme_provider.dart';
import '../data/local/hive_boxes.dart';
import '../data/local/models/app_settings.dart';
import '../data/services/notification_event_producer_service.dart';
import '../data/services/notification_service.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 ConsumerStatefulWidget {
const App({super.key});
@override
ConsumerState<App> createState() => _AppState();
}
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
Timer? _midnightResyncTimer;
DateTime? _lastPermissionCheckAt;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
HardwareKeyboard.instance.syncKeyboardState();
});
_scheduleMidnightResync();
NotificationService.instance.consumePendingLaunchRoute();
}
@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();
_checkNotificationPermissionOnResume();
}
}
@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();
});
}
Future<void> _checkNotificationPermissionOnResume() async {
final now = DateTime.now();
if (_lastPermissionCheckAt != null &&
now.difference(_lastPermissionCheckAt!).inSeconds < 30) {
return; // Throttle: max once per 30 seconds.
}
_lastPermissionCheckAt = now;
try {
final settings = Hive.box<AppSettings>(HiveBoxes.settings)
.get('default') ??
AppSettings();
if (!settings.adhanEnabled.values.any((v) => v)) return;
final permissionStatus =
await NotificationService.instance.getPermissionStatus();
await NotificationEventProducerService.instance
.emitPermissionWarningsIfNeeded(
settings: settings,
permissionStatus: permissionStatus,
);
} catch (_) {
// Non-blocking: permission check on resume is best-effort.
}
}
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(themeProvider);
return MaterialApp.router(
title: 'JamShalat',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeMode,
routerConfig: appRouter,
);
}
}