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:
2026-05-10 16:36:46 +08:00
parent 706149c75e
commit 7ae8f33b2c
12 changed files with 437 additions and 5 deletions

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

View 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

View File

@@ -4,6 +4,12 @@ import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/availability/mitra_availability_notifier.dart';
import '../../core/chat/active_session_notifier.dart';
import '../../core/notifications/notif_permission.dart';
import '../../core/theme/halo_tokens.dart';
/// Session-only dismiss flag for the "notif denied" banner. Resets on cold
/// restart by design — `StateProvider` lives in memory only.
final homeNotifBannerDismissedProvider = StateProvider<bool>((_) => false);
/// Home screen.
///
@@ -107,9 +113,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
child: ListView(
// Force-scroll so RefreshIndicator can fire even on a short body.
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(32),
padding: EdgeInsets.zero,
children: [
const SizedBox(height: 32),
const _NotifDeniedBanner(),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(height: 32),
),
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
const SizedBox(height: 32),
Center(
@@ -235,3 +245,76 @@ class _ActiveSessionCard extends StatelessWidget {
);
}
}
/// Above-the-fold amber banner shown when notif permission is denied. Tap
/// "nyalain" → opens app settings; tap the close icon → hides for the
/// in-memory session only (cold restart re-shows it).
class _NotifDeniedBanner extends ConsumerWidget {
const _NotifDeniedBanner();
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusAsync = ref.watch(notifPermissionStatusProvider);
final dismissed = ref.watch(homeNotifBannerDismissedProvider);
final isDenied = statusAsync.valueOrNull == NotifPermStatus.denied;
if (!isDenied || dismissed) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
color: HaloTokens.accentSoft,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
),
child: Row(
children: [
const Icon(
Icons.notifications_off_outlined,
size: 18,
color: HaloTokens.brandDark,
),
const SizedBox(width: HaloSpacing.s8),
const Expanded(
child: Text(
'notifikasi off — kamu bisa kelewat chat dari bestie',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
color: HaloTokens.ink,
),
),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s8,
),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
onPressed: () =>
ref.read(notifPermissionStatusProvider.notifier).openAppSettings(),
child: const Text('nyalain'),
),
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
color: HaloTokens.inkSoft,
icon: const Icon(Icons.close),
onPressed: () =>
ref.read(homeNotifBannerDismissedProvider.notifier).state = true,
),
],
),
);
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/notifications/notif_permission.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Phase 4 Stage 4 — post-payment notif gate.
///
/// If the OS notif permission is already granted on entry, the screen
/// auto-advances to the searching shell. Otherwise it offers two CTAs:
/// "izinkan notifikasi" (request) and "nanti aja" (skip). Either resolution
/// path advances to the searching shell — Stage 5 will refine that target if
/// the post-payment pairing entry needs to differ.
// TODO(stage5): if Stage 5 introduces a dedicated post-payment pairing entry,
// retarget the advance from `/chat/searching` to whichever route owns the
// blast trigger after a paid session.
class NotifGateScreen extends ConsumerStatefulWidget {
const NotifGateScreen({super.key});
@override
ConsumerState<NotifGateScreen> createState() => _NotifGateScreenState();
}
class _NotifGateScreenState extends ConsumerState<NotifGateScreen> {
static const String _advanceRoute = '/chat/searching';
bool _resolving = false;
bool _autoAdvanced = false;
void _advance() {
if (!mounted) return;
context.go(_advanceRoute);
}
Future<void> _onAllow() async {
if (_resolving) return;
setState(() => _resolving = true);
await ref.read(notifPermissionStatusProvider.notifier).request();
if (!mounted) return;
_advance();
}
void _onLater() => _advance();
@override
Widget build(BuildContext context) {
final statusAsync = ref.watch(notifPermissionStatusProvider);
// Already-granted users skip the screen entirely. Schedule the redirect
// post-frame so we don't `context.go` mid-build.
if (!_autoAdvanced &&
statusAsync.valueOrNull == NotifPermStatus.granted) {
_autoAdvanced = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _advance());
}
return PopScope(
// The notif gate sits between payment-confirmed and the searching
// shell. There is nothing meaningful to pop back to (the waiting
// screen is terminal once paid), so we swallow the back gesture.
canPop: false,
child: Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s32,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
Center(
child: Container(
width: 96,
height: 96,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
shape: BoxShape.circle,
),
child: const Icon(
Icons.notifications_active_outlined,
color: HaloTokens.brandDark,
size: 44,
),
),
),
const SizedBox(height: HaloSpacing.s24),
const Text(
'biar nggak ketinggalan',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 30 / 26,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'kami pingin kabarin kamu pas bestie udah siap dengerin. izinin notifikasi biar nggak ada chat yang kelewat.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 20 / 14,
color: HaloTokens.inkSoft,
),
),
const Spacer(),
HaloButton(
label: 'izinkan notifikasi',
fullWidth: true,
onPressed: _resolving ? null : _onAllow,
),
const SizedBox(height: HaloSpacing.s12),
HaloButton(
label: 'nanti aja',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: _resolving ? null : _onLater,
),
],
),
),
),
),
);
}
}

View File

@@ -125,11 +125,9 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
if (status == PaymentSessionStatus.confirmed ||
status == PaymentSessionStatus.consumed) {
_markTerminal();
// TODO(stage4): route to `/onboarding/notif-gate` once Stage 4 lands.
// For now, drop the user back home — Stage 5 will pick the pairing flow up.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.go('/');
context.go('/onboarding/notif-gate');
});
} else if (status == PaymentSessionStatus.expired ||
status == PaymentSessionStatus.abandoned) {

View File

@@ -10,6 +10,7 @@ import 'features/auth/screens/force_register_screen.dart';
import 'features/auth/screens/set_display_name_screen.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'features/onboarding/screens/esp_screen.dart';
import 'features/onboarding/screens/notif_gate_screen.dart';
import 'features/onboarding/screens/usp_screen.dart';
import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart';
@@ -147,6 +148,13 @@ GoRouter buildRouter(Ref ref) {
path: '/onboarding/anon/usp',
builder: (_, __) => const UspScreen(verified: false),
),
// Phase 4 Stage 4 — post-payment OS-notif gate. Sits under the
// `/onboarding/*` carve-out so the auth/onboarding redirect rules let
// anonymous + authenticated users transit through unhindered.
GoRoute(
path: '/onboarding/notif-gate',
builder: (_, __) => const NotifGateScreen(),
),
// Alias for the OTP-blocked popup's "lanjut tanpa verif" exit. The
// popup may fire from any point in the verified branch (after the
// user has already passed ESP+USP), so we expose a stable terminal