Files
halobestie-clone/client_app/lib/features/home/home_screen.dart
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

820 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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';
import 'providers/bestie_history_provider.dart';
import 'widgets/bestie_choice_sheet.dart';
import 'widgets/halo_tab_bar.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 — Phase 4 redesign.
///
/// Renders one of two variants depending on auth state, mirroring the Figma
/// `SHome1st` / `SHomeReturning` components named in
/// `requirement/flow_customer.mermaid.md` §1:
/// - `AuthInitialData` (no JWT) → SHome1st: top login-recover banner +
/// `aku mau curhat` CTA + empty curhatan card.
/// - `AuthAnonymousData` / `AuthAuthenticatedData` → SHomeReturning:
/// `halo, {name}` greeting + `curhat sama bestie baru` CTA + history list.
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(mitraAvailabilityProvider.notifier).setActive(true);
// Re-fetch history every time we re-enter /home. Covers post-chat
// return via thank_you_screen's `context.go('/home')`, where the
// home widget is freshly constructed and we want the just-completed
// session reflected without requiring pull-to-refresh.
ref.invalidate(bestieHistoryProvider);
});
}
@override
void deactivate() {
// setActive(false) lives here, NOT in dispose(): modern Riverpod
// invalidates `ref` as soon as the State enters dispose(), so calling
// `ref.read` from there throws `Bad state: Cannot use "ref" after the
// widget was disposed.` That exception fires inside `finalizeTree` and
// leaves the widget tree in a half-finalized state — observed symptom
// is a frozen screen on the next push (e.g. Home → Chat).
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
super.deactivate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final notifier = ref.read(mitraAvailabilityProvider.notifier);
if (state == AppLifecycleState.resumed) {
ref.read(activeSessionProvider.notifier).refresh();
ref.invalidate(bestieHistoryProvider);
notifier.setActive(true);
} else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
notifier.setActive(false);
}
}
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
/// when they have prior history, otherwise jump to the new-payment shell.
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
bool hasHistory;
try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
} catch (_) {
hasHistory = false;
}
if (!context.mounted) return;
if (hasHistory) {
await BestieChoiceSheet.show(context);
return;
}
context.push('/payment/entry');
}
/// CTA path for SHome1st. Per mermaid §2, fresh users hit S2 Nama first
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
/// and pushes into the verif-choice sheet.
void _onAkuMauCurhatPressed(BuildContext context) {
context.push('/auth/display-name');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authData = authState.valueOrNull;
// SHomeReturning needs a real session; everything else (AuthInitialData,
// AsyncError, transient OTP states) renders SHome1st with the login
// banner so an unauthenticated user is never shown the returning view.
final isReturning =
authData is AuthAuthenticatedData || authData is AuthAnonymousData;
final isFresh = !isReturning;
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
bottom: false,
child: Column(
children: [
const _NotifDeniedBanner(),
Expanded(
child: RefreshIndicator(
onRefresh: () async {
await Future.wait([
ref.read(activeSessionProvider.notifier).refresh(),
ref.read(mitraAvailabilityProvider.notifier).refresh(),
ref.refresh(bestieHistoryProvider.future),
]);
},
child: isFresh
? _SHome1stView(onCTA: () => _onAkuMauCurhatPressed(context))
: _SHomeReturningView(
onCTA: () => _onCurhatBestieBaruPressed(context),
),
),
),
const HaloTabBar(active: 'home'),
],
),
),
);
}
}
// ─── SHome1st ──────────────────────────────────────────────────────────────
class _SHome1stView extends ConsumerWidget {
final VoidCallback onCTA;
const _SHome1stView({required this.onCTA});
@override
Widget build(BuildContext context, WidgetRef ref) {
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
final mitraAvailable = availabilityAsync.valueOrNull ?? false;
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.zero,
children: [
const _LoginRecoverBanner(),
Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _GreetingHaloOnly(),
const SizedBox(height: 4),
const _GreetingSubtitle(),
const SizedBox(height: 32),
_PrimaryCTA(
label: 'aku mau curhat',
enabled: mitraAvailable,
onPressed: onCTA,
),
if (!mitraAvailable) ...[
const SizedBox(height: 12),
const Text(
'belum ada bestie tersedia',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkMuted,
),
),
],
const SizedBox(height: 28),
const _SectionLabel('curhatan sebelumnya'),
const SizedBox(height: 10),
const _HistoryEmptyCard(),
],
),
),
],
);
}
}
class _LoginRecoverBanner extends StatelessWidget {
const _LoginRecoverBanner();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Material(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.md,
child: InkWell(
borderRadius: HaloRadius.md,
onTap: () => context.push('/auth/register'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.brand.withValues(alpha: 0.2)),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
color: HaloTokens.brand,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
alignment: Alignment.center,
child: const Text('👋', style: TextStyle(fontSize: 15)),
),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'udah pernah pakai HaloBestie?',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.3,
),
),
SizedBox(height: 1),
Text(
'login buat lanjutin obrolan & history kamu',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
color: HaloTokens.inkSoft,
height: 1.3,
),
),
],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: HaloTokens.brand.withValues(alpha: 0.33)),
),
child: const Text(
'masuk →',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: HaloTokens.brand,
),
),
),
],
),
),
),
),
);
}
}
class _GreetingHaloOnly extends StatelessWidget {
const _GreetingHaloOnly();
@override
Widget build(BuildContext context) {
return const Text(
'halo,',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.64,
),
);
}
}
class _GreetingSubtitle extends StatelessWidget {
const _GreetingSubtitle();
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 290),
child: const Text(
'lagi ngerasa gimana hari ini? bestie akan nemenin kamu kok 🤍',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.55,
),
),
);
}
}
class _HistoryEmptyCard extends StatelessWidget {
const _HistoryEmptyCard();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(
color: HaloTokens.border,
width: 1,
style: BorderStyle.solid, // Flutter has no built-in dashed border;
// a CustomPainter could draw dashes — visual parity is close enough
// for v1 (border color + radius match Figma exactly).
),
),
alignment: Alignment.center,
child: const Text(
'belum ada curhatan. mulai aja, bestie udah siap 🌷',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkMuted,
),
),
);
}
}
// ─── SHomeReturning ────────────────────────────────────────────────────────
class _SHomeReturningView extends ConsumerWidget {
final VoidCallback onCTA;
const _SHomeReturningView({required this.onCTA});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authData = ref.watch(authProvider).valueOrNull;
final activeSessionAsync = ref.watch(activeSessionProvider);
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
final historyAsync = ref.watch(bestieHistoryProvider);
final mitraAvailable = availabilityAsync.valueOrNull ?? false;
final displayName = switch (authData) {
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
AuthAnonymousData d => d.displayName,
_ => '',
};
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
children: [
_GreetingHaloName(name: displayName),
const SizedBox(height: 4),
const _GreetingSubtitle(),
const SizedBox(height: 24),
activeSessionAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _PrimaryCTA(
label: 'curhat sama bestie baru',
enabled: mitraAvailable,
onPressed: onCTA,
),
data: (snapshot) {
final status = snapshot.session?['status'] as String?;
final isCurhatable = snapshot.hasSession && status != 'closing';
if (isCurhatable) {
return _ActiveSessionCard(
mitraName: snapshot.mitraName,
unreadCount: snapshot.unreadCount,
onTap: () {
final sessionId = snapshot.sessionId;
if (sessionId == null) return;
context.push('/chat/session/$sessionId',
extra: snapshot.mitraName);
},
);
}
return _PrimaryCTA(
label: 'curhat sama bestie baru',
enabled: mitraAvailable,
onPressed: onCTA,
);
},
),
if (!mitraAvailable) ...[
const SizedBox(height: 12),
const Text(
'belum ada bestie tersedia',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkMuted,
),
),
],
const SizedBox(height: 28),
const _SectionLabel('curhatan sebelumnya'),
const SizedBox(height: 10),
historyAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
error: (_, __) => const _HistoryEmptyCard(),
data: (items) {
if (items.isEmpty) return const _HistoryEmptyCard();
return Column(
children: [
for (var i = 0; i < items.length; i++) ...[
_HistoryItemTile(item: items[i], seed: i + 1),
if (i < items.length - 1) const SizedBox(height: 10),
],
],
);
},
),
],
);
}
}
class _GreetingHaloName extends StatelessWidget {
final String name;
const _GreetingHaloName({required this.name});
@override
Widget build(BuildContext context) {
final shown = name.trim().isEmpty ? 'kamu' : name;
return Text.rich(
TextSpan(
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
height: 1.15,
letterSpacing: -0.64,
),
children: [
const TextSpan(
text: 'halo, ',
style: TextStyle(color: HaloTokens.brandDark),
),
TextSpan(
text: shown,
style: const TextStyle(color: HaloTokens.brand),
),
],
),
);
}
}
class _HistoryItemTile extends StatelessWidget {
final BestieHistoryItem item;
final int seed;
const _HistoryItemTile({required this.item, required this.seed});
@override
Widget build(BuildContext context) {
final relative = _relativeWhen(item.endedAt);
final topicLine = item.topics.isEmpty ? '' : '"${item.topics.first}"';
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
borderRadius: HaloRadius.lg,
onTap: () => context.push('/chat/transcript/${item.sessionId}'),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
child: Row(
children: [
_OrbPlaceholder(seed: seed),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
item.mitraName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
),
const SizedBox(width: 6),
Text(
relative,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 10.5,
color: HaloTokens.inkMuted,
),
),
],
),
if (topicLine.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
topicLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.inkSoft,
height: 1.4,
),
),
],
if (item.endedAt != null) ...[
const SizedBox(height: 4),
const Text(
'sesi habis · tap untuk lanjutin curhat',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 10,
fontStyle: FontStyle.italic,
color: HaloTokens.inkMuted,
),
),
],
],
),
),
],
),
),
),
);
}
static String _relativeWhen(DateTime? when) {
if (when == null) return '';
final diff = DateTime.now().difference(when);
if (diff.inDays >= 7) return '${(diff.inDays / 7).floor()} mgg lalu';
if (diff.inDays >= 1) return '${diff.inDays} hari lalu';
if (diff.inHours >= 1) return '${diff.inHours} jam lalu';
if (diff.inMinutes >= 1) return '${diff.inMinutes} mnt lalu';
return 'baru saja';
}
}
/// Placeholder for the Figma `HBOrb` gradient component. v1 ships as a
/// solid-color disc keyed off `seed`; a real animated orb is a follow-up.
class _OrbPlaceholder extends StatelessWidget {
final int seed;
const _OrbPlaceholder({required this.seed});
@override
Widget build(BuildContext context) {
const palette = [
HaloTokens.brand,
HaloTokens.lilac,
HaloTokens.mint,
HaloTokens.accent,
];
final color = palette[seed % palette.length];
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [color.withValues(alpha: 0.95), color.withValues(alpha: 0.55)],
),
shape: BoxShape.circle,
),
);
}
}
// ─── Shared ────────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: HaloTokens.inkMuted,
letterSpacing: 0.69,
),
);
}
}
class _PrimaryCTA extends StatelessWidget {
final String label;
final bool enabled;
final VoidCallback onPressed;
const _PrimaryCTA({
required this.label,
required this.enabled,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: HaloRadius.xl,
boxShadow: enabled ? HaloShadows.button : const [],
),
child: Material(
color: enabled ? HaloTokens.brand : HaloTokens.brandSofter,
borderRadius: HaloRadius.xl,
child: InkWell(
borderRadius: HaloRadius.xl,
onTap: enabled ? onPressed : null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
child: Center(
child: Text(
label,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
color: enabled ? Colors.white : HaloTokens.inkMuted,
),
),
),
),
),
),
),
);
}
}
class _ActiveSessionCard extends StatelessWidget {
final String mitraName;
final int unreadCount;
final VoidCallback onTap;
const _ActiveSessionCard({
required this.mitraName,
required this.unreadCount,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
elevation: 2,
shadowColor: HaloTokens.brand.withValues(alpha: 0.2),
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.lg,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Badge(
isLabelVisible: unreadCount > 0,
label: Text('$unreadCount'),
child: const CircleAvatar(
backgroundColor: HaloTokens.success,
child: Icon(Icons.chat, color: Colors.white),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'sesi aktif',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: 4),
Text(
'sedang curhat dengan $mitraName',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
color: HaloTokens.inkSoft,
),
),
],
),
),
const Icon(Icons.chevron_right, color: HaloTokens.inkMuted),
],
),
),
),
);
}
}
/// Above-the-fold amber banner shown when notif permission is denied. Tap
/// "nyalain" → opens app settings; tap close → hides for the in-memory
/// session (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,
),
],
),
);
}
}