Files
halobestie-clone/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart
Ramadhan Sjamsani eeb4ea38fc feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app:
- AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider
- FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor)
- user_id = customer UUID, user_type property, set on auth resolve/upgrade
- funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view,
  payment_view, payment_method_select, payment_started, pairing_matched/no_bestie
- bottom-sheet events: verif_choice_view/select, bestie_choice_view/select,
  extension_offer_view, chat_extension_requested
- payment_started carries app_instance_id + ga_session_id in the
  /payment-requests body for future server-side stitching (backend ignores)
- curhat_mode_pick screen name disambiguates the chat/call mode picker
  (/payment/method-pick) from the payment-channel picker (/payment/method)
- unify both home CTAs to "Aku Mau Curhat"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:26 +08:00

449 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_button.dart';
import '../../payment/state/payment_draft_provider.dart';
/// Extension-only pricing sheet — Phase 4 Stage 6 layout.
///
/// Used solely for in-session extension requests; the initial pairing flow
/// goes through `/payment` instead. Free-trial is never offered for extensions.
///
/// Mirrors the Stage 3 duration-pick screen: chat/call toggle on top,
/// 5-option tier list below, single CTA at the bottom. The `perpanjang`
/// behavior is unchanged from Phase 3.7 — submit calls
/// [SessionClosure.requestExtension], which runs the payment-session
/// create+confirm and then the extend POST.
class PricingBottomSheet extends ConsumerStatefulWidget {
/// Required — the in-progress chat session id this extension targets.
final String extensionSessionId;
/// Optional — fired when the customer chooses the "cukup, akhiri sesi"
/// ghost CTA. The sheet pops itself before invoking the callback so the
/// downstream confirm-end popup chain can stack on a clean route.
final VoidCallback? onEndSession;
const PricingBottomSheet({
super.key,
required this.extensionSessionId,
this.onEndSession,
});
/// Show for session extension (from chat screen).
static Future<void> showForExtension(
BuildContext context, {
required String sessionId,
VoidCallback? onEndSession,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: HaloTokens.surface,
// Material 3 auto-injects a drag-handle widget above the sheet when
// the theme has one configured. We render our own pill inside
// `_Body` to control its exact tint/size — explicitly disable the
// auto one so the user doesn't see two stacked horizontal lines.
showDragHandle: false,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
builder: (_) => PricingBottomSheet(
extensionSessionId: sessionId,
onEndSession: onEndSession,
),
);
}
@override
ConsumerState<PricingBottomSheet> createState() => _PricingBottomSheetState();
}
class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
PaymentMode _mode = PaymentMode.chat;
String? _selectedDurationId;
List<PriceTier> _tiersForMode(PricingData pricing) {
// Phase 4 — chat/call tier groups. Falls back to legacy `tiers` when the
// backend hasn't been cut over yet (so the sheet still works locally
// against an old backend).
if (_mode == PaymentMode.call) {
return pricing.callTiers.isNotEmpty ? pricing.callTiers : pricing.tiers;
}
return pricing.chatTiers.isNotEmpty ? pricing.chatTiers : pricing.tiers;
}
void _onTierTap(PriceTier tier) {
setState(() {
_selectedDurationId = tier.id ?? tier.durationMinutes.toString();
});
}
void _onConfirm(PriceTier tier) {
// ignore: discarded_futures
ref.read(analyticsProvider).logChatExtensionRequested(
sessionId: widget.extensionSessionId,
);
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
widget.extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
void _onEndSession() {
Navigator.of(context).pop();
widget.onEndSession?.call();
}
@override
Widget build(BuildContext context) {
final pricingAsync = ref.watch(chatPricingProvider);
return DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.5,
maxChildSize: 0.92,
expand: false,
builder: (_, scrollController) {
return SafeArea(
top: false,
child: pricingAsync.when(
loading: () => const SizedBox(
height: 240,
child: Center(child: CircularProgressIndicator()),
),
error: (_, __) => const SizedBox(
height: 240,
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
),
data: (pricing) => _Body(
pricing: pricing,
mode: _mode,
selectedDurationId: _selectedDurationId,
tiers: _tiersForMode(pricing),
scrollController: scrollController,
onModeChanged: (m) => setState(() {
_mode = m;
_selectedDurationId = null;
}),
onTierTap: _onTierTap,
onConfirm: _onConfirm,
onEndSession: widget.onEndSession != null ? _onEndSession : null,
),
),
);
},
);
}
}
class _Body extends StatelessWidget {
final PricingData pricing;
final PaymentMode mode;
final String? selectedDurationId;
final List<PriceTier> tiers;
final ScrollController scrollController;
final ValueChanged<PaymentMode> onModeChanged;
final ValueChanged<PriceTier> onTierTap;
final ValueChanged<PriceTier> onConfirm;
final VoidCallback? onEndSession;
const _Body({
required this.pricing,
required this.mode,
required this.selectedDurationId,
required this.tiers,
required this.scrollController,
required this.onModeChanged,
required this.onTierTap,
required this.onConfirm,
required this.onEndSession,
});
@override
Widget build(BuildContext context) {
final selectedTier = tiers.firstWhere(
(t) => (t.id ?? t.durationMinutes.toString()) == selectedDurationId,
orElse: () => const PriceTier(durationMinutes: 0, price: 0, label: ''),
);
final hasSelection = selectedTier.durationMinutes > 0;
final ctaLabel = hasSelection
? '${mode == PaymentMode.call ? '📞' : '💬'} perpanjang ${formatRupiah(selectedTier.price)}'
: 'pilih durasi dulu';
return Column(
children: [
const SizedBox(height: HaloSpacing.s8),
Container(
width: 40,
height: 4,
decoration: const BoxDecoration(
color: HaloTokens.border,
borderRadius: HaloRadius.pill,
),
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'waktu curhat habis',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
const SizedBox(height: HaloSpacing.s4),
const Text(
'mau tambah waktu?',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
child: _ModeToggle(mode: mode, onChanged: onModeChanged),
),
const SizedBox(height: HaloSpacing.s12),
Expanded(
child: tiers.isEmpty
? const _EmptyState()
: ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s4,
HaloSpacing.s24,
HaloSpacing.s16,
),
itemCount: tiers.length,
separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s8),
itemBuilder: (context, i) {
final tier = tiers[i];
final id = tier.id ?? tier.durationMinutes.toString();
final selected = id == selectedDurationId;
return _TierCard(
tier: tier,
selected: selected,
onTap: () => onTierTap(tier),
);
},
),
),
Padding(
// No top border here — the drag handle is the only top "divider"
// the figma shows. A second border above the action bar reads as a
// duplicate line stacked under the list separator visuals.
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s12,
HaloSpacing.s24,
HaloSpacing.s16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
HaloButton(
label: ctaLabel,
size: HaloButtonSize.lg,
fullWidth: true,
onPressed: hasSelection ? () => onConfirm(selectedTier) : null,
),
if (onEndSession != null) ...[
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: 'cukup, akhiri sesi',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: onEndSession,
),
],
],
),
),
],
);
}
}
class _ModeToggle extends StatelessWidget {
final PaymentMode mode;
final ValueChanged<PaymentMode> onChanged;
const _ModeToggle({required this.mode, required this.onChanged});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.pill,
),
child: Row(
children: [
Expanded(child: _Pill(label: 'chat', selected: mode == PaymentMode.chat, onTap: () => onChanged(PaymentMode.chat))),
Expanded(child: _Pill(label: 'call', selected: mode == PaymentMode.call, onTap: () => onChanged(PaymentMode.call))),
],
),
);
}
}
class _Pill extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _Pill({required this.label, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: selected ? HaloTokens.surface : Colors.transparent,
borderRadius: HaloRadius.pill,
child: InkWell(
borderRadius: HaloRadius.pill,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s8),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: selected ? HaloTokens.brandDark : HaloTokens.inkSoft,
),
),
),
),
);
}
}
class _TierCard extends StatelessWidget {
final PriceTier tier;
final bool selected;
final VoidCallback onTap;
const _TierCard({required this.tier, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: selected ? HaloTokens.brandSofter : HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
borderRadius: HaloRadius.lg,
onTap: onTap,
child: AnimatedContainer(
duration: HaloMotion.fast,
padding: const EdgeInsets.all(HaloSpacing.s16),
decoration: BoxDecoration(
border: Border.all(
color: selected ? HaloTokens.brand : HaloTokens.border,
width: selected ? 2 : 1,
),
borderRadius: HaloRadius.lg,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: const BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.md,
),
alignment: Alignment.center,
child: Text(
'${tier.durationMinutes}',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Row(
children: [
Text(
'${tier.durationMinutes} menit',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
if (tier.tag != null) ...[
const SizedBox(width: HaloSpacing.s8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s8,
vertical: 2,
),
decoration: const BoxDecoration(
color: HaloTokens.mint,
borderRadius: HaloRadius.pill,
),
child: Text(
tier.tag!,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: Color(0xFF1F4D34),
letterSpacing: 0.4,
),
),
),
],
],
),
),
Text(
formatRupiah(tier.price),
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
],
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.all(HaloSpacing.s24),
child: Text(
'Belum ada paket untuk mode ini.',
style: TextStyle(color: HaloTokens.inkSoft),
),
),
);
}
}