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

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