diff --git a/client_app/android/app/src/main/AndroidManifest.xml b/client_app/android/app/src/main/AndroidManifest.xml index a296cfb..19f6bef 100644 --- a/client_app/android/app/src/main/AndroidManifest.xml +++ b/client_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + CADisableMinimumFrameDurationOnPhone + NSUserNotificationsUsageDescription + Halo Bestie kirim notifikasi pas bestie udah siap dengerin dan pas ada chat baru. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/client_app/lib/core/notifications/notif_permission.dart b/client_app/lib/core/notifications/notif_permission.dart new file mode 100644 index 0000000..e33db5d --- /dev/null +++ b/client_app/lib/core/notifications/notif_permission.dart @@ -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 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 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 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((_) => 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 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 request() async { + final result = await ref.read(_helperProvider).request(); + state = AsyncData(result); + return result; + } + + Future openAppSettings() => + ref.read(_helperProvider).openAppSettings(); + + /// Force a re-read — used after returning from app settings. + Future 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(); + } + } +} diff --git a/client_app/lib/core/notifications/notif_permission.g.dart b/client_app/lib/core/notifications/notif_permission.g.dart new file mode 100644 index 0000000..a096a17 --- /dev/null +++ b/client_app/lib/core/notifications/notif_permission.g.dart @@ -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.internal( + NotifPermissionStatus.new, + name: r'notifPermissionStatusProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$notifPermissionStatusHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$NotifPermissionStatus = AsyncNotifier; +// 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 diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index b602c51..054bb55 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -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((_) => false); /// Home screen. /// @@ -107,9 +113,13 @@ class _HomeScreenState extends ConsumerState 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, + ), + ], + ), + ); + } +} diff --git a/client_app/lib/features/onboarding/screens/notif_gate_screen.dart b/client_app/lib/features/onboarding/screens/notif_gate_screen.dart new file mode 100644 index 0000000..fb69c50 --- /dev/null +++ b/client_app/lib/features/onboarding/screens/notif_gate_screen.dart @@ -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 createState() => _NotifGateScreenState(); +} + +class _NotifGateScreenState extends ConsumerState { + static const String _advanceRoute = '/chat/searching'; + + bool _resolving = false; + bool _autoAdvanced = false; + + void _advance() { + if (!mounted) return; + context.go(_advanceRoute); + } + + Future _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, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/waiting_payment_screen.dart b/client_app/lib/features/payment/screens/waiting_payment_screen.dart index 21b21e1..dd07a53 100644 --- a/client_app/lib/features/payment/screens/waiting_payment_screen.dart +++ b/client_app/lib/features/payment/screens/waiting_payment_screen.dart @@ -125,11 +125,9 @@ class _WaitingPaymentScreenState extends ConsumerState 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) { diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index c1f2b7d..536e93a 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -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 diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 8759b61..e2c7632 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -792,6 +792,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 3019321..8510085 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -42,6 +42,10 @@ dependencies: # (mock mode encodes payment_session_id; real QR will come from Xendit later). 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: flutter_test: sdk: flutter diff --git a/client_app/windows/flutter/generated_plugin_registrant.cc b/client_app/windows/flutter/generated_plugin_registrant.cc index 39cedd3..0433e3c 100644 --- a/client_app/windows/flutter/generated_plugin_registrant.cc +++ b/client_app/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/client_app/windows/flutter/generated_plugins.cmake b/client_app/windows/flutter/generated_plugins.cmake index 6ffe921..de4a7d2 100644 --- a/client_app/windows/flutter/generated_plugins.cmake +++ b/client_app/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core flutter_secure_storage_windows + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST