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:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user