Phase 4 Stage 6: chat-room countdown UX + voice-call mode pill

Customer chat screen:
- Voice-call header pill (mode == 'call' renders accent-colored pill;
  chat mode renders no pill).
- HaloSnackbar fires once per session at 180s remaining ('sisa 3 menit
  lagi ya 🤍'), driven by the backend session_warning WS event.
- Last-2-min danger styling: timer pill flips to HaloTokens.danger +
  bold JetBrainsMono when remaining <= 120s.
- Floating ChatExpiredBanner widget injected above the input bar when
  remaining hits 0 in closing-grace state. perpanjang -> existing
  pricing bottom sheet.
- pricing_bottom_sheet.dart rewritten to the 5-option layout with
  chat|call mode toggle (mirrors duration-pick from Stage 3).

Mitra chat screen: voice-call header pill only (no countdown UX per PRD).

Backend:
- session.service.js getSessionById JOINs payment_sessions so mode +
  expires_at ship in /api/shared/chat/:id/info.
- session-timer.service.js onThreeMinuteWarning now emits expires_at +
  remaining_seconds for client resync.
- Dev-only POST /internal/_test/force-session-expires-at clears the
  3-min flag, reschedules the timer, and broadcasts WS resync. Lets
  the Maestro flow drive 175s -> 90s -> 0s without waiting live.

New chatRemainingSeconds StreamProvider derived from expiresAt, fed by
session_warning / session_timer / session_expired resync messages
(plan referenced a secondsLeftProvider that didn't actually exist).

Maestro 06_chat_countdown.yaml + force_session_expires_at.js helper.

Out of scope: meet.google.com URL launching - url_launcher isn't a
client_app dependency and message bubbles render plain Text. Defer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:25:11 +08:00
parent f170d54535
commit 14b5cc966b
14 changed files with 902 additions and 75 deletions

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
@@ -32,6 +33,12 @@ class ChatConnectedData extends ChatData {
final bool sessionClosing;
final bool goodbyeSubmitted;
final Map<String, dynamic>? extensionResponse;
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
final SessionMode mode;
// Phase 4 — drives the client-side seconds-left ticker. Backend only emits
// discrete `session_timer` (60s) + `session_warning` (180s) events, so we
// tick locally off this absolute timestamp for the danger pill / banner.
final DateTime? expiresAt;
const ChatConnectedData({
required this.messages,
@@ -42,6 +49,8 @@ class ChatConnectedData extends ChatData {
this.sessionClosing = false,
this.goodbyeSubmitted = false,
this.extensionResponse,
this.mode = SessionMode.chat,
this.expiresAt,
});
ChatConnectedData copyWith({
@@ -53,6 +62,8 @@ class ChatConnectedData extends ChatData {
bool? sessionClosing,
bool? goodbyeSubmitted,
Map<String, dynamic>? extensionResponse,
SessionMode? mode,
DateTime? expiresAt,
}) {
return ChatConnectedData(
messages: messages ?? this.messages,
@@ -63,6 +74,8 @@ class ChatConnectedData extends ChatData {
sessionClosing: sessionClosing ?? this.sessionClosing,
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
extensionResponse: extensionResponse ?? this.extensionResponse,
mode: mode ?? this.mode,
expiresAt: expiresAt ?? this.expiresAt,
);
}
}
@@ -102,6 +115,25 @@ class ChatMessage {
}
}
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
/// Backend only emits discrete `session_timer` (60s remaining) and
/// `session_warning` (180s remaining) events; the danger pill / expired banner
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
/// chat state and re-emits every second while a session is connected.
@riverpod
Stream<int> chatRemainingSeconds(Ref ref) async* {
final chatState = ref.watch(chatProvider);
if (chatState is! ChatConnectedData) return;
final expiresAt = chatState.expiresAt;
if (expiresAt == null) return;
while (true) {
final remaining = expiresAt.difference(DateTime.now()).inSeconds;
yield remaining < 0 ? 0 : remaining;
if (remaining <= 0) return;
await Future<void>.delayed(const Duration(seconds: 1));
}
}
@Riverpod(keepAlive: true)
class Chat extends _$Chat {
WebSocketChannel? _channel;
@@ -109,10 +141,22 @@ class Chat extends _$Chat {
Timer? _typingTimer;
String? _connectedSessionId;
// Phase 4 — broadcast stream of `session_warning.kind` strings (e.g.
// `three_minutes_left`). Screens listen via [warningStream] to fire one-shot
// UI like the 3-min snackbar. Kept separate from state so the warning
// doesn't accidentally re-fire on rebuild.
final _warningController = StreamController<String>.broadcast();
Stream<String> get warningStream => _warningController.stream;
ApiClient get _apiClient => ref.read(apiClientProvider);
@override
ChatData build() => const ChatInitialData();
ChatData build() {
ref.onDispose(() {
_warningController.close();
});
return const ChatInitialData();
}
/// Idempotent connect: if we're already connected to [sessionId], refresh
/// the session status from the server (in case it transitioned to closing /
@@ -155,11 +199,16 @@ class Chat extends _$Chat {
return;
}
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
final mode = SessionMode.fromString(data?['mode'] as String?);
final expiresAtRaw = data?['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
state = current.copyWith(
sessionClosing: status == SessionStatus.closing,
sessionPaused: status == SessionStatus.extending,
sessionExpired: false,
goodbyeSubmitted: goodbyeSubmittedByMe,
mode: mode,
expiresAt: expiresAt,
);
} catch (e) {
// ignore: avoid_print
@@ -184,6 +233,9 @@ class Chat extends _$Chat {
final isClosing = sessionStatus == SessionStatus.closing;
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
final mode = SessionMode.fromString(sessionData?['mode'] as String?);
final expiresAtRaw = sessionData?['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>;
@@ -225,6 +277,8 @@ class Chat extends _$Chat {
messages: messages,
sessionClosing: isClosing,
goodbyeSubmitted: goodbyeSubmittedByMe,
mode: mode,
expiresAt: expiresAt,
);
} catch (e) {
state = const ChatErrorData('Gagal terhubung ke chat.');
@@ -351,11 +405,41 @@ class Chat extends _$Chat {
case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?;
state = current.copyWith(remainingSeconds: remaining);
// When the server includes expires_at (Phase 4 dev resync + future
// periodic ticks), update the local ticker reference. Backwards-
// compatible: pre-Phase-4 events without `expires_at` are no-ops here.
final expiresAtRaw = data['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
state = current.copyWith(
remainingSeconds: remaining,
expiresAt: expiresAt,
);
break;
case WsMessage.sessionWarning:
// Forward to listeners (chat screen drives a one-shot snackbar). Stream
// is broadcast — subscribers may or may not be present; cheap if not.
final kind = data['kind'] as String?;
// Resync the local ticker — server may have shifted expires_at since
// we last connected (e.g. extension, dev shortcut). Without this, the
// last-2-min danger pill / expired banner can't track real time.
final expiresAtRaw = data['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
if (expiresAt != null) {
state = current.copyWith(expiresAt: expiresAt);
}
if (kind != null) _warningController.add(kind);
break;
case WsMessage.sessionExpired:
state = current.copyWith(sessionExpired: true);
// Snap the local ticker to 0 so the floating expired banner appears
// immediately. The server-side expires_at may have shifted (e.g.
// dev /force-session-expires-at) ahead of our last refresh, so we
// can't rely on the existing expiresAt value to reach 0 on its own.
state = current.copyWith(
sessionExpired: true,
expiresAt: DateTime.now(),
);
break;
case WsMessage.sessionPaused:

View File

@@ -6,7 +6,31 @@ part of 'chat_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5';
String _$chatRemainingSecondsHash() =>
r'd7bce1bffe7d3034b6f4905194ead4dfaf473c92';
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
/// Backend only emits discrete `session_timer` (60s remaining) and
/// `session_warning` (180s remaining) events; the danger pill / expired banner
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
/// chat state and re-emits every second while a session is connected.
///
/// Copied from [chatRemainingSeconds].
@ProviderFor(chatRemainingSeconds)
final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
chatRemainingSeconds,
name: r'chatRemainingSecondsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$chatRemainingSecondsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -64,6 +64,21 @@ class ExtensionStatus {
ExtensionStatus._();
}
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
/// no real audio transport is built yet.
enum SessionMode {
chat('chat'),
call('call');
final String value;
const SessionMode(this.value);
static SessionMode fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => SessionMode.chat);
}
/// Session topic sensitivity
enum TopicSensitivity {
regular('regular'),
@@ -101,6 +116,9 @@ class WsMessage {
static const sessionCompleted = 'session_completed';
static const sessionPaused = 'session_paused';
static const sessionResumed = 'session_resumed';
// Phase 4 — soft countdown warning (`kind: 'three_minutes_left'`).
// Customer-only: mitra never sees a countdown.
static const sessionWarning = 'session_warning';
// Extension
static const extensionRequest = 'extension_request';

View File

@@ -6,6 +6,9 @@ import '../../../core/chat/active_session_notifier.dart';
import '../../../core/chat/chat_notifier.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_snackbar.dart';
import '../widgets/chat_expired_banner.dart';
import '../widgets/pricing_bottom_sheet.dart';
// Chat theme colors
@@ -31,9 +34,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final _goodbyeController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
StreamSubscription<String>? _warningSub;
bool _showBestieBanner = true;
bool _showUserBanner = true;
bool _expiredDialogShown = false;
// Per-session-mount idempotency flag for the 3-min snackbar. The backend
// also guards once-per-session (timers.threeMinFired), but a fresh mount
// could still receive the event on a refreshed status pull, so we belt-
// and-braces here.
bool _threeMinShown = false;
@override
void initState() {
@@ -48,6 +57,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
ref.read(sessionClosureProvider.notifier).reset();
ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId);
});
// Subscribe to the chat notifier's session-warning stream. Using stream
// subscription rather than a `ref.listen` on state because the warning is
// a one-shot signal, not a persistent state field.
_warningSub = ref.read(chatProvider.notifier).warningStream.listen((kind) {
if (kind == 'three_minutes_left' && !_threeMinShown && mounted) {
_threeMinShown = true;
HaloSnackbar.show(
context,
'sisa 3 menit lagi ya 🤍',
icon: '',
);
}
});
}
@override
@@ -56,6 +78,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
_goodbyeController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
_warningSub?.cancel();
super.dispose();
// Intentionally do NOT disconnect the WS here. The global lifecycle in
// `App` decides when to disconnect (logout / no active session).
@@ -178,6 +201,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
});
// Phase 4 — derived ticker drives the danger pill / expired banner.
// Only watched when there's a connected session with a known expires_at.
final remainingAsync = ref.watch(chatRemainingSecondsProvider);
final remainingTick = remainingAsync.value;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
@@ -193,29 +221,75 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
icon: const Icon(Icons.chevron_left, size: 28),
onPressed: _exitChat,
),
title: Text(widget.mitraName),
actions: [
if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${chatState.remainingSeconds}s',
style: TextStyle(
color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black,
fontWeight: FontWeight.bold,
),
),
title: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.mitraName,
overflow: TextOverflow.ellipsis,
),
),
if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[
const SizedBox(width: 8),
_buildVoiceCallPill(),
],
],
),
actions: [
if (chatState is ChatConnectedData && remainingTick != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(child: _buildTimerPill(remainingTick)),
),
],
),
body: _buildBody(chatState, closureState),
body: _buildBody(chatState, closureState, remainingTick),
),
);
}
Widget _buildBody(ChatData chatState, SessionClosureData closureState) {
Widget _buildVoiceCallPill() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: const BoxDecoration(
color: HaloTokens.accent,
borderRadius: HaloRadius.pill,
),
child: const Text(
'📞 Voice Call',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
);
}
Widget _buildTimerPill(int remaining) {
final danger = remaining <= 120;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: danger ? HaloTokens.danger : Colors.transparent,
borderRadius: HaloRadius.pill,
),
child: Text(
formatCountdown(remaining),
style: TextStyle(
fontFamily: HaloTokens.fontMono,
fontSize: 13,
fontWeight: danger ? FontWeight.w700 : FontWeight.w600,
color: danger ? Colors.white : HaloTokens.ink,
),
),
);
}
Widget _buildBody(ChatData chatState, SessionClosureData closureState, int? remainingTick) {
if (chatState is ChatConnectingData) {
return const Center(child: CircularProgressIndicator());
}
@@ -223,12 +297,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return Center(child: Text(chatState.message));
}
if (chatState is ChatConnectedData) {
return _buildChatBody(chatState, closureState);
return _buildChatBody(chatState, closureState, remainingTick);
}
return const SizedBox.shrink();
}
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState, int? remainingTick) {
// Show goodbye composer when closure flow is in goodbye/submitting OR when
// we mounted directly into a `closing` session (e.g. opened from history).
// The chatProvider listener can't catch this case because it only fires on
@@ -303,6 +377,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
),
),
// Floating expired banner — visible while the timer has hit zero
// and the session hasn't been finalized yet (still in closing
// grace). Tapping `perpanjang` opens the time-up sheet, same as
// the modal route.
if (remainingTick != null && remainingTick <= 0)
ChatExpiredBanner(
onExtend: () => PricingBottomSheet.showForExtension(
context,
sessionId: widget.sessionId,
),
),
// Input bar — disabled when timer expired (modal handles next step)
if (!state.sessionExpired) _buildInputBar(),
],

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_button.dart';
/// Floating banner injected above the chat input bar when the session timer
/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6:
/// gives the customer a soft, in-place way to extend instead of the modal-only
/// flow from Phase 3.
class ChatExpiredBanner extends StatelessWidget {
final VoidCallback onExtend;
const ChatExpiredBanner({super.key, required this.onExtend});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(
HaloSpacing.s12,
HaloSpacing.s8,
HaloSpacing.s12,
HaloSpacing.s8,
),
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s16,
HaloSpacing.s12,
HaloSpacing.s12,
HaloSpacing.s12,
),
decoration: const BoxDecoration(
color: HaloTokens.danger,
borderRadius: HaloRadius.lg,
boxShadow: HaloShadows.card,
),
child: Row(
children: [
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: HaloSpacing.s12),
const Expanded(
child: Text(
'waktu curhat habis',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
HaloButton(
label: 'perpanjang',
size: HaloButtonSize.sm,
variant: HaloButtonVariant.secondary,
onPressed: onExtend,
),
],
),
);
}
}

View File

@@ -3,15 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.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.
/// 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.
///
/// Submit triggers [SessionClosure.requestExtension], which internally
/// runs the payment-session create+confirm and then the extend POST.
class PricingBottomSheet extends ConsumerWidget {
/// 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;
@@ -22,62 +28,372 @@ class PricingBottomSheet extends ConsumerWidget {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: HaloTokens.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
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) {
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
widget.extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
@override
Widget build(BuildContext context) {
final pricingAsync = ref.watch(chatPricingProvider);
return pricingAsync.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (error, _) => const SizedBox(
height: 200,
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
),
data: (pricing) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Padding(
padding: const EdgeInsets.all(24),
child: ListView(
controller: scrollController,
children: [
const Text(
'Perpanjang Durasi',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// No free-trial path for extensions.
...pricing.tiers.map((tier) => Card(
child: ListTile(
title: Text(tier.label),
trailing: Text(
formatRupiah(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
},
),
)),
],
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,
),
),
);
},
);
}
}
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;
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,
});
@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),
);
},
),
),
Container(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s8,
HaloSpacing.s24,
HaloSpacing.s24,
),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: HaloButton(
label: ctaLabel,
size: HaloButtonSize.lg,
fullWidth: true,
onPressed: hasSelection ? () => onConfirm(selectedTier) : null,
),
),
],
);
}
}
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),
),
),
);
}