Phase 4 Stage 4: notif gate + home permission-denied banner
Notif Gate full screen at /onboarding/notif-gate, reached from waiting payment on confirmed/consumed status. Auto-advances to /chat/searching when permission is already granted; otherwise shows izinkan/nanti aja HaloButton CTAs. NotifPermission helper wraps firebase_messaging + permission_handler with readStatus/request/openAppSettings; cached in notifPermissionStatusProvider that re-reads on app foreground via an internal WidgetsBindingObserver. home_screen amber banner above-the-fold when notifPermissionStatusProvider reports denied. Dismissable for the session via homeNotifBannerDismissedProvider (in-memory StateProvider, no persistence - cold-restart re-shows). nyalain CTA -> openAppSettings(). Manifest + Info.plist permission entries added. Note: main.dart still pre-requests FirebaseMessaging permission at boot, which can pre-resolve status so the gate auto-advances instead of acting as the first prompt. Left intact for now; can be removed in a later stage if the gate should be the first-ask UX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
client_app/lib/core/notifications/notif_permission.dart
Normal file
115
client_app/lib/core/notifications/notif_permission.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as ph;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'notif_permission.g.dart';
|
||||
|
||||
enum NotifPermStatus { notDetermined, granted, denied }
|
||||
|
||||
/// Wraps `firebase_messaging` + `permission_handler` for the Phase 4 Stage 4
|
||||
/// notif gate. Reads/requests are platform-routed:
|
||||
/// - iOS uses Firebase Messaging (which surfaces the system UNNotification
|
||||
/// authorization status).
|
||||
/// - Android 13+ uses `permission_handler` for `Permission.notification`
|
||||
/// (POST_NOTIFICATIONS runtime). Older Android always reports granted.
|
||||
class NotifPermission {
|
||||
const NotifPermission();
|
||||
|
||||
Future<NotifPermStatus> readStatus() async {
|
||||
final phStatus = await ph.Permission.notification.status;
|
||||
return _mapPh(phStatus);
|
||||
}
|
||||
|
||||
/// Shows the OS prompt only when status is [NotifPermStatus.notDetermined].
|
||||
/// Otherwise returns the current status without re-prompting (the OS would
|
||||
/// no-op anyway on a previously-resolved permission).
|
||||
Future<NotifPermStatus> request() async {
|
||||
final current = await readStatus();
|
||||
if (current != NotifPermStatus.notDetermined) return current;
|
||||
|
||||
// Firebase Messaging requestPermission triggers the iOS prompt; on Android
|
||||
// it is a no-op for permission UI but registers the FCM iOS APNS token.
|
||||
// We still call permission_handler.request() so Android 13+ shows the
|
||||
// POST_NOTIFICATIONS dialog.
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
final phResult = await ph.Permission.notification.request();
|
||||
return _mapPh(phResult);
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() => ph.openAppSettings();
|
||||
|
||||
NotifPermStatus _mapPh(ph.PermissionStatus s) {
|
||||
if (s.isGranted || s.isLimited || s.isProvisional) {
|
||||
return NotifPermStatus.granted;
|
||||
}
|
||||
if (s.isDenied) return NotifPermStatus.notDetermined;
|
||||
// permanentlyDenied + restricted both behave like "denied — open settings".
|
||||
return NotifPermStatus.denied;
|
||||
}
|
||||
}
|
||||
|
||||
final _helperProvider = Provider<NotifPermission>((_) => const NotifPermission());
|
||||
|
||||
/// Cached notif permission status. Auto-refreshes on app foreground via an
|
||||
/// internal `WidgetsBindingObserver` — there is no shared `appLifecycleProvider`
|
||||
/// in this codebase yet, so the observer is owned here.
|
||||
@Riverpod(keepAlive: true)
|
||||
class NotifPermissionStatus extends _$NotifPermissionStatus {
|
||||
_LifecycleHook? _hook;
|
||||
|
||||
@override
|
||||
Future<NotifPermStatus> build() async {
|
||||
_hook ??= _LifecycleHook(_onResumed);
|
||||
ref.onDispose(() {
|
||||
_hook?.detach();
|
||||
_hook = null;
|
||||
});
|
||||
return ref.read(_helperProvider).readStatus();
|
||||
}
|
||||
|
||||
/// Triggers the OS prompt (only if [NotifPermStatus.notDetermined]) and
|
||||
/// re-publishes the resolved status.
|
||||
Future<NotifPermStatus> request() async {
|
||||
final result = await ref.read(_helperProvider).request();
|
||||
state = AsyncData(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() =>
|
||||
ref.read(_helperProvider).openAppSettings();
|
||||
|
||||
/// Force a re-read — used after returning from app settings.
|
||||
Future<void> refresh() async {
|
||||
final s = await ref.read(_helperProvider).readStatus();
|
||||
if (state.valueOrNull == s) return;
|
||||
state = AsyncData(s);
|
||||
}
|
||||
|
||||
void _onResumed() {
|
||||
// Fire-and-forget; refresh is idempotent.
|
||||
// ignore: unawaited_futures
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
class _LifecycleHook with WidgetsBindingObserver {
|
||||
_LifecycleHook(this._onResumed) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
final VoidCallback _onResumed;
|
||||
|
||||
void detach() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_onResumed();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
client_app/lib/core/notifications/notif_permission.g.dart
Normal file
31
client_app/lib/core/notifications/notif_permission.g.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notif_permission.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$notifPermissionStatusHash() =>
|
||||
r'16c81af5e48dab2c7d0cf33c985e0ca7c3d01006';
|
||||
|
||||
/// Cached notif permission status. Auto-refreshes on app foreground via an
|
||||
/// internal `WidgetsBindingObserver` — there is no shared `appLifecycleProvider`
|
||||
/// in this codebase yet, so the observer is owned here.
|
||||
///
|
||||
/// Copied from [NotifPermissionStatus].
|
||||
@ProviderFor(NotifPermissionStatus)
|
||||
final notifPermissionStatusProvider =
|
||||
AsyncNotifierProvider<NotifPermissionStatus, NotifPermStatus>.internal(
|
||||
NotifPermissionStatus.new,
|
||||
name: r'notifPermissionStatusProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$notifPermissionStatusHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$NotifPermissionStatus = AsyncNotifier<NotifPermStatus>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
Reference in New Issue
Block a user