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:
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Android 13+ runtime notification permission. Requested by the
|
||||||
|
Phase 4 Stage 4 notif-gate via permission_handler. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="client_app"
|
android:label="client_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
|
<string>Halo Bestie kirim notifikasi pas bestie udah siap dengerin dan pas ada chat baru.</string>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
|||||||
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
|
||||||
@@ -4,6 +4,12 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/availability/mitra_availability_notifier.dart';
|
import '../../core/availability/mitra_availability_notifier.dart';
|
||||||
import '../../core/chat/active_session_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.
|
/// Home screen.
|
||||||
///
|
///
|
||||||
@@ -107,9 +113,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
// Force-scroll so RefreshIndicator can fire even on a short body.
|
// Force-scroll so RefreshIndicator can fire even on a short body.
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(32),
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
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))),
|
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Center(
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,11 +125,9 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
|
|||||||
if (status == PaymentSessionStatus.confirmed ||
|
if (status == PaymentSessionStatus.confirmed ||
|
||||||
status == PaymentSessionStatus.consumed) {
|
status == PaymentSessionStatus.consumed) {
|
||||||
_markTerminal();
|
_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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.go('/');
|
context.go('/onboarding/notif-gate');
|
||||||
});
|
});
|
||||||
} else if (status == PaymentSessionStatus.expired ||
|
} else if (status == PaymentSessionStatus.expired ||
|
||||||
status == PaymentSessionStatus.abandoned) {
|
status == PaymentSessionStatus.abandoned) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'features/auth/screens/force_register_screen.dart';
|
|||||||
import 'features/auth/screens/set_display_name_screen.dart';
|
import 'features/auth/screens/set_display_name_screen.dart';
|
||||||
import 'features/onboarding/onboarding_screen.dart';
|
import 'features/onboarding/onboarding_screen.dart';
|
||||||
import 'features/onboarding/screens/esp_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/onboarding/screens/usp_screen.dart';
|
||||||
import 'features/splash/splash_screen.dart';
|
import 'features/splash/splash_screen.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
@@ -147,6 +148,13 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
path: '/onboarding/anon/usp',
|
path: '/onboarding/anon/usp',
|
||||||
builder: (_, __) => const UspScreen(verified: false),
|
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
|
// Alias for the OTP-blocked popup's "lanjut tanpa verif" exit. The
|
||||||
// popup may fire from any point in the verified branch (after 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
|
// user has already passed ESP+USP), so we expose a stable terminal
|
||||||
|
|||||||
@@ -792,6 +792,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.4.0"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.1.0"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.7"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ dependencies:
|
|||||||
# (mock mode encodes payment_session_id; real QR will come from Xendit later).
|
# (mock mode encodes payment_session_id; real QR will come from Xendit later).
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
|
|
||||||
|
# OS notification permission — used by the post-payment notif gate
|
||||||
|
# (Phase 4 Stage 4) and the home banner.
|
||||||
|
permission_handler: ^11.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
|
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
permission_handler_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user