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>
643 lines
22 KiB
Dart
643 lines
22 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/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
|
|
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 _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() {
|
|
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');
|
|
}
|
|
}
|
|
|
|
Future<void> _showSessionExpiredDialog() async {
|
|
if (_expiredDialogShown) return;
|
|
_expiredDialogShown = true;
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Waktu Curhat Berakhir'),
|
|
content: const Text(
|
|
'Sesi curhatmu sudah habis waktunya. Kamu bisa menutup obrolan atau memperpanjang waktu untuk lanjut bicara.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(dialogContext).pop();
|
|
_exitChat();
|
|
},
|
|
child: const Text('Tutup'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(dialogContext).pop();
|
|
PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId);
|
|
},
|
|
child: const Text('Perpanjang'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final chatState = ref.watch(chatProvider);
|
|
final closureState = ref.watch(sessionClosureProvider);
|
|
|
|
// Listen for closure complete to navigate home
|
|
ref.listen(sessionClosureProvider, (prev, next) {
|
|
if (next is ClosureCompleteData) {
|
|
// Make doubly sure home picks up the cleared session.
|
|
ref.invalidate(activeSessionProvider);
|
|
context.go('/home');
|
|
}
|
|
});
|
|
|
|
// Listen for chat state changes to manage closure state and timer-expired modal
|
|
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();
|
|
}
|
|
}
|
|
// Timer-expired: show non-dismissible modal once on false→true flip.
|
|
final wasExpired = prev is ChatConnectedData && prev.sessionExpired;
|
|
if (next.sessionExpired && !wasExpired) {
|
|
_showSessionExpiredDialog();
|
|
}
|
|
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
|
final closure = ref.read(sessionClosureProvider);
|
|
if (closure is! ClosureInitialData) {
|
|
ref.read(sessionClosureProvider.notifier).reset();
|
|
}
|
|
// If we're back to a healthy active state, allow the modal to fire
|
|
// again on a later expiry (e.g. after extension then re-expiry).
|
|
_expiredDialogShown = false;
|
|
}
|
|
_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: 12),
|
|
child: Center(child: _buildTimerPill(remainingTick)),
|
|
),
|
|
],
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|