Files
halobestie-clone/client_app/lib/features/onboarding/screens/notif_gate_screen.dart
ramadhan sjamsani 7ae8f33b2c 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>
2026-05-10 16:36:46 +08:00

137 lines
4.7 KiB
Dart

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,
),
],
),
),
),
),
);
}
}