Bestie Choice Sheet on home Mulai Curhat CTA. When the user has at least one prior session (bestieHistoryHasItemsProvider hits the chat- sessions history endpoint), the CTA opens a HaloBottomSheet with two cards: 'bestie yang udah kenal' -> /chat/history, 'bestie baru' -> /payment/entry. Empty history -> direct to /payment/entry. Bestie history list visual upgrade: HaloOrb (mitraId seed) + name + last-session date + topic pills + sessions count + ONLINE pill. Backend getCustomerHistory now returns topics, mitra_is_online, sessions_count in a single payload (no per-row presence round-trip). BestieOfflinePopup with two variants (returning | new_) replacing the legacy BestieUnavailableDialog. tanya admin ghost CTA on both variants opens the new TanyaAdminSheet. Stage 5's targeted-wait declined stub + Stage 7's chat-screen 409 stub + searching-screen call site all migrated to the real component. TanyaAdminSheet: HaloBottomSheet with WA + Telegram buttons, deeplinks fetched via supportHandlesProvider (CC-config-driven). url_launcher added to client_app; ios LSApplicationQueriesSchemes covers https/http/whatsapp/tg. Stage 2's OTP-blocked popup hubungi admin SnackBar stub also migrated to TanyaAdminSheet. Dev-only POST /internal/_test/seed-history-session lets Maestro 08 flow seed a history row before exercising the choice sheet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
688 lines
24 KiB
Dart
688 lines
24 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../../core/chat/active_session_notifier.dart';
|
|
import '../../../core/chat/chat_notifier.dart';
|
|
import '../../../core/chat/session_closure_notifier.dart';
|
|
import '../../../core/config/app_config_provider.dart';
|
|
import '../../../core/constants.dart';
|
|
import '../../../core/theme/halo_tokens.dart';
|
|
import '../../../core/theme/widgets/halo_snackbar.dart';
|
|
import '../widgets/bestie_unavailable_dialog.dart';
|
|
import '../widgets/chat_expired_banner.dart';
|
|
import '../widgets/closing_message_sheet.dart';
|
|
import '../widgets/confirm_end_step1.dart';
|
|
import '../widgets/confirm_end_step2.dart';
|
|
import '../widgets/pricing_bottom_sheet.dart';
|
|
|
|
// Chat theme colors
|
|
const _kUserBubbleColor = Color(0xFFD4929A);
|
|
const _kBgTint = Color(0xFFF5D0D6);
|
|
const _kBannerColor = Color(0xFFC4868F);
|
|
const _kAccentPink = Color(0xFFBE7C8A);
|
|
const _kEndedBannerColor = Color(0xFFFFE0B2); // soft amber for early-end notice
|
|
const _kEndedBannerText = Color(0xFF8B5A00);
|
|
|
|
class ChatScreen extends ConsumerStatefulWidget {
|
|
final String sessionId;
|
|
final String mitraName;
|
|
|
|
const ChatScreen({super.key, required this.sessionId, required this.mitraName});
|
|
|
|
@override
|
|
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|
final _messageController = TextEditingController();
|
|
final _goodbyeController = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
Timer? _typingThrottle;
|
|
StreamSubscription<String>? _warningSub;
|
|
bool _showBestieBanner = true;
|
|
bool _showUserBanner = true;
|
|
bool _rejectPopupShown = 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() {
|
|
super.initState();
|
|
// The chat WebSocket is owned globally by `App` (see main.dart).
|
|
// We just ask it to be on this session — no-op if it already is.
|
|
Future.microtask(() {
|
|
// Reset any closure state left over from a prior session view (the
|
|
// closure notifier is keepAlive, so e.g. `ClosureCompleteData` from
|
|
// the last goodbye submission would otherwise leak into this mount
|
|
// and suppress the goodbye composer for a fresh `closing` session).
|
|
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
|
|
void dispose() {
|
|
_messageController.dispose();
|
|
_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).
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onTextChanged(String text) {
|
|
if (_typingThrottle?.isActive ?? false) return;
|
|
ref.read(chatProvider.notifier).sendTyping();
|
|
_typingThrottle = Timer(const Duration(seconds: 2), () {});
|
|
}
|
|
|
|
void _sendMessage() {
|
|
final text = _messageController.text.trim();
|
|
if (text.isEmpty) return;
|
|
ref.read(chatProvider.notifier).sendMessage(text);
|
|
_messageController.clear();
|
|
_scrollToBottom();
|
|
}
|
|
|
|
void _exitChat() {
|
|
if (context.canPop()) {
|
|
context.pop();
|
|
} else {
|
|
context.go('/home');
|
|
}
|
|
}
|
|
|
|
/// Stage 7 entry point — wired to both the AppBar "akhiri sesi" button and
|
|
/// the menu equivalent. Reads `endSessionTwoStepConfirmProvider`: when the
|
|
/// flag is `true` the user sees step-1 first; when `false` (A/B variant) we
|
|
/// jump straight to step-2 (write-message vs skip).
|
|
Future<void> _onAkhiriSesiTapped() async {
|
|
final twoStep = ref.read(endSessionTwoStepConfirmProvider);
|
|
if (!twoStep) {
|
|
_showStep2();
|
|
return;
|
|
}
|
|
await ConfirmEndStep1.show(context, onConfirm: _showStep2);
|
|
}
|
|
|
|
void _showStep2() {
|
|
if (!mounted) return;
|
|
ConfirmEndStep2.show(
|
|
context,
|
|
onWriteMessage: _showClosingSheet,
|
|
onSkip: _closeWithoutMessage,
|
|
);
|
|
}
|
|
|
|
void _showClosingSheet() {
|
|
if (!mounted) return;
|
|
ClosingMessageSheet.show(
|
|
context,
|
|
sessionId: widget.sessionId,
|
|
onCompleted: _goToThankYou,
|
|
);
|
|
}
|
|
|
|
Future<void> _closeWithoutMessage() async {
|
|
await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId);
|
|
// Navigation is driven by the closure listener (success path) or the
|
|
// ClosureRejectedByMitraData branch (409 fallback popup).
|
|
}
|
|
|
|
void _goToThankYou() {
|
|
if (!mounted) return;
|
|
context.go('/chat/thank-you');
|
|
}
|
|
|
|
Future<void> _showBestieReturningPopup() async {
|
|
if (_rejectPopupShown) return;
|
|
_rejectPopupShown = true;
|
|
if (!mounted) return;
|
|
await BestieOfflinePopup.show(
|
|
context,
|
|
variant: BestieOfflineVariant.returning,
|
|
mitraName: widget.mitraName,
|
|
);
|
|
_rejectPopupShown = false;
|
|
// Reset closure state so the user can retry without a stale-error block.
|
|
ref.read(sessionClosureProvider.notifier).reset();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final chatState = ref.watch(chatProvider);
|
|
final closureState = ref.watch(sessionClosureProvider);
|
|
|
|
// Stage 7 — closure outcomes drive routing. Success ends in S11 thank-you;
|
|
// 409 surfaces the bestie-returning fallback popup (Stage 8 owns the
|
|
// dedicated component).
|
|
ref.listen(sessionClosureProvider, (prev, next) {
|
|
if (next is ClosureCompleteData) {
|
|
ref.invalidate(activeSessionProvider);
|
|
_goToThankYou();
|
|
} else if (next is ClosureRejectedByMitraData) {
|
|
_showBestieReturningPopup();
|
|
}
|
|
});
|
|
|
|
// Listen for chat state changes to manage closure state. Stage 7 removed
|
|
// the legacy `_showSessionExpiredDialog` modal — the Stage 6 ChatExpiredBanner
|
|
// is the in-place replacement, and the user reaches the closing flow via
|
|
// the AppBar "akhiri" button.
|
|
ref.listen(chatProvider, (prev, next) {
|
|
if (next is ChatConnectedData) {
|
|
// Early-end (mitra/customer ended before timer): show goodbye composer.
|
|
if (next.sessionClosing && !next.sessionExpired) {
|
|
final closure = ref.read(sessionClosureProvider);
|
|
if (closure is ClosureInitialData) {
|
|
ref.read(sessionClosureProvider.notifier).declineExtension();
|
|
}
|
|
}
|
|
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
|
final closure = ref.read(sessionClosureProvider);
|
|
if (closure is! ClosureInitialData) {
|
|
ref.read(sessionClosureProvider.notifier).reset();
|
|
}
|
|
}
|
|
_scrollToBottom();
|
|
final unread = next.messages
|
|
.where((m) => m.senderType == UserType.mitra && m.status != MessageStatus.read)
|
|
.map((m) => m.id)
|
|
.toList();
|
|
if (unread.isNotEmpty) {
|
|
ref.read(chatProvider.notifier).markRead(unread);
|
|
// Optimistically clear the home badge.
|
|
ref.read(activeSessionProvider.notifier).markRead();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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, _) {
|
|
if (!didPop) _exitChat();
|
|
},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: Colors.black,
|
|
elevation: 0.5,
|
|
centerTitle: true,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.chevron_left, size: 28),
|
|
onPressed: _exitChat,
|
|
),
|
|
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: 4),
|
|
child: Center(child: _buildTimerPill(remainingTick)),
|
|
),
|
|
if (chatState is ChatConnectedData &&
|
|
!chatState.sessionClosing)
|
|
TextButton(
|
|
onPressed: _onAkhiriSesiTapped,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: HaloTokens.brandDark,
|
|
textStyle: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
child: const Text('akhiri'),
|
|
),
|
|
],
|
|
),
|
|
body: _buildBody(chatState, closureState, remainingTick),
|
|
),
|
|
);
|
|
}
|
|
|
|
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());
|
|
}
|
|
if (chatState is ChatErrorData) {
|
|
return Center(child: Text(chatState.message));
|
|
}
|
|
if (chatState is ChatConnectedData) {
|
|
return _buildChatBody(chatState, closureState, remainingTick);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
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
|
|
// transitions, not the current state at mount time.
|
|
// Suppress when the customer has already submitted their goodbye — the
|
|
// session can stay in `closing` while waiting for the mitra to submit
|
|
// their own message or for the 5-min grace timer to auto-complete.
|
|
final shouldShowGoodbye = !state.goodbyeSubmitted &&
|
|
(closureState is ClosureShowGoodbyeData ||
|
|
closureState is ClosureSubmittingData ||
|
|
(state.sessionClosing &&
|
|
!state.sessionExpired &&
|
|
closureState is! ClosureCompleteData));
|
|
if (shouldShowGoodbye) {
|
|
return _buildGoodbyeView(closureState);
|
|
}
|
|
|
|
if (state.sessionClosing && state.goodbyeSubmitted) {
|
|
return _buildAwaitingMitraGoodbyeView(state);
|
|
}
|
|
|
|
if (state.sessionPaused) {
|
|
return _buildPausedView();
|
|
}
|
|
|
|
return Stack(
|
|
children: [
|
|
// Background pattern
|
|
Positioned.fill(
|
|
child: Container(
|
|
color: _kBgTint,
|
|
child: Image.asset(
|
|
'assets/images/chat_pattern.png',
|
|
repeat: ImageRepeat.repeat,
|
|
fit: BoxFit.none,
|
|
),
|
|
),
|
|
),
|
|
// Content
|
|
Column(
|
|
children: [
|
|
// Entry banners
|
|
if (_showBestieBanner)
|
|
_buildEntryBanner(
|
|
'[Bestie] Sudah Memasuki Ruangan',
|
|
() => setState(() => _showBestieBanner = false),
|
|
),
|
|
if (_showUserBanner)
|
|
_buildEntryBanner(
|
|
'[User] Sudah Memasuki Ruangan',
|
|
() => setState(() => _showUserBanner = false),
|
|
),
|
|
// Messages
|
|
Expanded(
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: state.messages.length,
|
|
itemBuilder: (context, index) {
|
|
final msg = state.messages[index];
|
|
final isMe = msg.senderType == UserType.customer;
|
|
return _buildMessageBubble(msg, isMe);
|
|
},
|
|
),
|
|
),
|
|
// Typing indicator
|
|
if (state.isOtherTyping)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
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(),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildEntryBanner(String text, VoidCallback onDismiss) {
|
|
return Container(
|
|
color: _kBannerColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.volume_up, color: Colors.white, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
|
),
|
|
GestureDetector(
|
|
onTap: onDismiss,
|
|
child: const Icon(Icons.close, color: Colors.white, size: 18),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBubble(ChatMessage msg, bool isMe) {
|
|
return Align(
|
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
|
|
decoration: BoxDecoration(
|
|
color: isMe ? _kUserBubbleColor : Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(msg.content, style: const TextStyle(fontSize: 15)),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
|
|
style: TextStyle(fontSize: 10, color: isMe ? Colors.white70 : Colors.grey),
|
|
),
|
|
if (isMe) ...[
|
|
const SizedBox(width: 4),
|
|
_buildStatusIcon(msg.status),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusIcon(String status) {
|
|
switch (status) {
|
|
case 'sending':
|
|
return const Icon(Icons.access_time, size: 14, color: Colors.white70);
|
|
case MessageStatus.sent:
|
|
return const Icon(Icons.check, size: 14, color: Colors.white70);
|
|
case MessageStatus.delivered:
|
|
return const Icon(Icons.done_all, size: 14, color: Colors.white70);
|
|
case MessageStatus.read:
|
|
return const Icon(Icons.done_all, size: 14, color: Colors.white);
|
|
default:
|
|
return const SizedBox.shrink();
|
|
}
|
|
}
|
|
|
|
Widget _buildInputBar() {
|
|
return SafeArea(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
color: Colors.white,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _messageController,
|
|
onChanged: _onTextChanged,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _sendMessage(),
|
|
decoration: InputDecoration(
|
|
hintText: 'Ketik Pesan',
|
|
hintStyle: TextStyle(color: Colors.grey.shade400),
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
color: _kAccentPink,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.send, color: Colors.white, size: 20),
|
|
onPressed: _sendMessage,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// TODO(phase4-followup): Stage 7 moved the customer-initiated goodbye flow
|
|
// to ClosingMessageSheet. This inline composer is still reachable when the
|
|
// mitra ends a session early (sessionClosing fired by the server). Migrate
|
|
// that path to the new sheet too once the early-end UX is finalised.
|
|
Widget _buildGoodbyeView(SessionClosureData closureState) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
children: [
|
|
// Early-end banner — visually distinct, separate from the closing
|
|
// composer below. We don't surface "Perpanjang" in this flow.
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: _kEndedBannerColor,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.info_outline, color: _kEndedBannerText, size: 20),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Sesi telah ditutup oleh Bestie',
|
|
style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
const Icon(Icons.waving_hand, size: 64, color: Colors.amber),
|
|
const SizedBox(height: 16),
|
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 8),
|
|
const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center),
|
|
const SizedBox(height: 24),
|
|
TextField(
|
|
controller: _goodbyeController,
|
|
maxLines: 3,
|
|
decoration: InputDecoration(
|
|
hintText: 'Terima kasih, Bestie...',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: closureState is ClosureSubmittingData
|
|
? null
|
|
: () {
|
|
final text = _goodbyeController.text.trim();
|
|
if (text.isNotEmpty) {
|
|
ref.read(sessionClosureProvider.notifier).submitGoodbye(
|
|
widget.sessionId, text,
|
|
);
|
|
}
|
|
},
|
|
child: closureState is ClosureSubmittingData
|
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Text('Kirim & Selesai'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPausedView() {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 24),
|
|
Text('Menunggu konfirmasi Bestie...', style: TextStyle(fontSize: 18)),
|
|
SizedBox(height: 8),
|
|
Text('Chat dijeda sementara', style: TextStyle(color: Colors.grey)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAwaitingMitraGoodbyeView(ChatConnectedData state) {
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: Container(
|
|
color: _kBgTint,
|
|
child: Image.asset(
|
|
'assets/images/chat_pattern.png',
|
|
repeat: ImageRepeat.repeat,
|
|
fit: BoxFit.none,
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
color: _kEndedBannerColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.hourglass_top, color: _kEndedBannerText, size: 20),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Pesan penutupmu sudah terkirim. Menunggu Bestie...',
|
|
style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: state.messages.length,
|
|
itemBuilder: (context, index) {
|
|
final msg = state.messages[index];
|
|
final isMe = msg.senderType == UserType.customer;
|
|
return _buildMessageBubble(msg, isMe);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|