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

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