Mitra Bestie §1–§3: shell + Undangan + popup + chat polish

Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 11:14:30 +08:00
parent fcb8eaa505
commit fbc94daac7
59 changed files with 5039 additions and 687 deletions

View File

@@ -4,9 +4,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../chat_request_notifier.dart';
import '../../constants.dart';
import '../../../router.dart';
import '../../theme/halo_tokens.dart';
import 'sensitivity_badge.dart';
import 'sensitivity_theme.dart';
/// Centered modal overlay for incoming chat requests.
///
/// Visual restyle of the Figma `BestieIncomingPopup` (v5.jsx:129). Two
/// variants are rendered from the same widget:
/// - new (PairingRequestType.general) — pink-bordered card, 📨 emoji,
/// 'Curhat Baru!' headline, 'Terima' button.
/// - extend (PairingRequestType.returning) — amber-bordered card, ⚡ emoji,
/// 'Perpanjang Curhat' headline, 'Terima Perpanjangan' button, '+N mnt'
/// duration badge.
///
/// The accept/decline behavior, countdown timer, and stale handling are
/// unchanged from the prior bottom-sheet implementation; only the visual
/// layout differs.
class ChatRequestOverlay extends ConsumerStatefulWidget {
final Widget child;
const ChatRequestOverlay({super.key, required this.child});
@@ -18,27 +32,37 @@ class ChatRequestOverlay extends ConsumerStatefulWidget {
class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _scaleAnimation;
bool _visible = false;
// Returning-chat countdown. Server is the source of truth on auto-reject;
// this is purely visual. When it hits 0 we dismiss the overlay and let the server's
// chat_request_closed event (or stale state) take over.
// this is purely visual. When it hits 0 we dismiss the overlay and let the
// server's chat_request_closed event (or stale state) take over.
Timer? _countdownTimer;
int? _secondsRemaining;
String? _countdownSessionId;
// Tracks the last sessionId we surfaced a 'taken by other Bestie' snackbar
// for, so re-entering the same stale state doesn't queue duplicates.
String? _lastStaleSnackbarSessionId;
// Snapshot of the most recently displayed incoming request. Kept so the
// card can stay visible during ChatRequestAcceptingData (the notifier
// drops the incoming payload on that transition) and render the spinner
// inside the accept button instead of swapping to a placeholder card.
ChatRequestIncomingData? _lastIncoming;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
duration: HaloMotion.normal,
);
_scaleAnimation = CurvedAnimation(
parent: _animController,
curve: HaloMotion.ease,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic));
}
@override
@@ -70,8 +94,8 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
if (!mounted) return;
final remaining = (_secondsRemaining ?? 0) - 1;
if (remaining <= 0) {
// Auto-dismiss UI only — server fires the actual auto-reject and will follow up
// with a chat_request_closed event. Do NOT call decline from the client here.
// Auto-dismiss UI only — server fires the actual auto-reject and will
// follow up with chat_request_closed. Do NOT call decline from here.
setState(() => _secondsRemaining = 0);
_stopCountdown();
_hide();
@@ -89,27 +113,13 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
}
void _maybeStartCountdownFor(ChatRequestIncomingData data) {
final timeout = data.confirmationTimeoutSeconds;
if (data.requestType == PairingRequestType.returning &&
timeout != null &&
timeout > 0) {
// Restart only if this is a different session than the one we're already counting.
if (_countdownSessionId != data.sessionId) {
_startCountdown(data.sessionId, timeout);
}
} else {
_stopCountdown();
}
}
void _onSwipeDown(DragEndDetails details) {
if (details.primaryVelocity != null && details.primaryVelocity! > 200) {
final state = ref.read(chatRequestProvider);
if (state is ChatRequestIncomingData) {
ref.read(chatRequestProvider.notifier).ignore();
} else if (state is ChatRequestStaleData) {
ref.read(chatRequestProvider.notifier).acknowledgeStale();
}
// Both variants now show a live countdown to be consistent with Figma
// ('30 detik' for new, '10 detik' for extend). Fall back to a sensible
// default if the server didn't send a timeout.
final timeout = data.confirmationTimeoutSeconds ??
(data.requestType == PairingRequestType.returning ? 10 : 30);
if (_countdownSessionId != data.sessionId) {
_startCountdown(data.sessionId, timeout);
}
}
@@ -117,22 +127,52 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
Widget build(BuildContext context) {
ref.listen(chatRequestProvider, (prev, next) {
if (next is ChatRequestIncomingData) {
_lastIncoming = next;
_show();
_maybeStartCountdownFor(next);
} else if (next is ChatRequestStaleData) {
// Stale message replaces the active request — kill any returning-chat countdown.
_stopCountdown();
_show();
// Race-condition path: another Bestie grabbed it. Show a transient
// snackbar instead of the modal card, and auto-advance the queue so
// the next pending request (if any) takes over.
if (next.reason == StaleReason.acceptedByOther &&
_lastStaleSnackbarSessionId != next.sessionId) {
_lastStaleSnackbarSessionId = next.sessionId;
_hide();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.hideCurrentSnackBar();
messenger?.showSnackBar(
const SnackBar(
content: Text('Pesan sudah diambil bestie lain 💛'),
duration: Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
});
// Auto-ack so the notifier moves on to the next queued invite.
ref.read(chatRequestProvider.notifier).acknowledgeStale();
} else {
_show();
}
} else if (next is ChatRequestAcceptedData) {
_hide();
// Navigate to chat session
final session = next.session;
final sessionId = session['session_id'] as String? ?? session['id'] as String;
final sessionId =
session['session_id'] as String? ?? session['id'] as String;
final router = ref.read(routerProvider);
router.push('/chat/session/$sessionId', extra: {
'customerName': session['customer_display_name'] as String? ?? 'Customer',
'customerName':
session['customer_display_name'] as String? ?? 'Customer',
});
} else if (next is ChatRequestAcceptingData) {
// Keep the modal visible while the accept call is in flight; the
// button content swaps to a spinner. _lastIncoming retains the
// card content.
} else {
_lastIncoming = null;
_hide();
}
});
@@ -142,32 +182,44 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
child: Stack(
children: [
widget.child,
if (_visible) ...[
// Semi-transparent dim
Positioned.fill(
child: GestureDetector(
onTap: () {}, // Block taps but don't dismiss
if (_visible)
Positioned.fill(
child: FadeTransition(
opacity: _animController,
child: Container(color: Colors.black.withOpacity(0.3)),
child: Stack(
children: [
// Dim backdrop — taps are absorbed but DO NOT dismiss.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: Container(
color: Colors.black.withValues(alpha: 0.5),
),
),
),
Center(
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1.0)
.animate(_scaleAnimation),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s20,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: _buildContent(),
),
),
),
),
),
],
),
),
),
),
// Overlay content
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SlideTransition(
position: _slideAnimation,
child: GestureDetector(
onVerticalDragEnd: _onSwipeDown,
child: _buildContent(),
),
),
),
],
],
),
);
}
@@ -176,199 +228,419 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
final requestState = ref.watch(chatRequestProvider);
if (requestState is ChatRequestIncomingData) {
return _buildActiveRequest(requestState);
return _IncomingCard(
data: requestState,
secondsRemaining:
_countdownSessionId == requestState.sessionId ? _secondsRemaining : null,
accepting: false,
onAccept: () => ref
.read(chatRequestProvider.notifier)
.accept(requestState.sessionId),
onDecline: () => ref
.read(chatRequestProvider.notifier)
.decline(requestState.sessionId),
);
}
if (requestState is ChatRequestAcceptingData) {
// Keep the same card visible with both buttons disabled and the
// accept button replaced by a spinner. Falls back to a minimal
// placeholder card if for any reason we don't have the prior data
// (e.g. cold start via setIncomingFromNotification).
final prev = _lastIncoming;
if (prev == null) return const _AcceptingCard();
return _IncomingCard(
data: prev,
secondsRemaining:
_countdownSessionId == prev.sessionId ? _secondsRemaining : null,
accepting: true,
onAccept: () {},
onDecline: () {},
);
}
if (requestState is ChatRequestStaleData) {
return _buildStaleRequest(requestState);
return _StaleCard(
data: requestState,
onAck: () =>
ref.read(chatRequestProvider.notifier).acknowledgeStale(),
);
}
return const SizedBox.shrink();
}
}
Widget _buildActiveRequest(ChatRequestIncomingData data) {
final durationText = data.isFreeTrial == true
? 'Free Trial'
: data.durationMinutes != null
? '${data.durationMinutes} Menit'
: '';
/// Pink/amber bordered modal card for an active incoming request.
class _IncomingCard extends StatelessWidget {
const _IncomingCard({
required this.data,
required this.secondsRemaining,
required this.accepting,
required this.onAccept,
required this.onDecline,
});
final ChatRequestIncomingData data;
final int? secondsRemaining;
final bool accepting;
final VoidCallback onAccept;
final VoidCallback onDecline;
bool get _isExtend => data.requestType == PairingRequestType.returning;
Color get _accent =>
_isExtend ? HaloTokens.accentAmber : HaloTokens.brand;
String get _emoji => _isExtend ? '' : '📨';
String get _headline => _isExtend ? 'Perpanjang Curhat' : 'Curhat Baru!';
String get _acceptLabel {
if (_isExtend) {
final mins = data.durationMinutes;
return mins != null ? 'Terima · +$mins mnt' : 'Terima Perpanjangan';
}
return 'Terima';
}
@override
Widget build(BuildContext context) {
final isSensitive = data.topicSensitivity == TopicSensitivity.sensitive;
final theme = SensitivityTheme.of(data.topicSensitivity);
final isReturning = data.requestType == PairingRequestType.returning;
final showCountdown = isReturning &&
_countdownSessionId == data.sessionId &&
_secondsRemaining != null;
final headlineText =
isReturning ? 'Customer ingin chat lagi!' : 'Ada permintaan chat baru!';
final subtitleText = isReturning
? 'Seorang customer yang pernah chat denganmu ingin lanjut.'
: 'Seorang customer ingin curhat denganmu.';
final sensitivityTheme = SensitivityTheme.of(data.topicSensitivity);
final defaultCountdown = _isExtend ? 10 : 30;
final remaining = secondsRemaining ?? defaultCountdown;
final urgent = remaining <= 10;
final countdownColor =
urgent ? HaloTokens.danger : HaloTokens.inkSoft;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
border: isSensitive
? Border(top: BorderSide(color: theme.badgeBg, width: 4))
: null,
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: _accent, width: 2),
boxShadow: HaloShadows.card,
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header: emoji + headline
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
Icon(
isReturning ? Icons.replay_circle_filled : Icons.chat,
size: 48,
color: isReturning ? Colors.deepPurple : Colors.blue,
),
const SizedBox(height: 12),
Text(
headlineText,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
_emoji,
style: const TextStyle(fontSize: 32),
),
const SizedBox(height: 4),
if (durationText.isNotEmpty)
Text(
'Durasi: $durationText',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
if (isSensitive) ...[
const SizedBox(height: 8),
SensitivityBadge(sensitivity: data.topicSensitivity, fontSize: 12),
],
const SizedBox(height: 8),
Text(
subtitleText,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
if (showCountdown) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.orange.shade200),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Text(
_headline,
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: _isExtend ? HaloTokens.accentAmber : HaloTokens.brandDark,
height: 1.15,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, size: 16, color: Colors.orange.shade800),
const SizedBox(width: 6),
Text(
'Konfirmasi dalam ${_secondsRemaining}s',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.orange.shade800,
),
),
],
),
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
ref.read(chatRequestProvider.notifier).decline(data.sessionId);
},
child: const Text('Tolak'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
ref.read(chatRequestProvider.notifier).accept(data.sessionId);
},
child: const Text('Terima'),
),
),
],
),
const SizedBox(height: 8),
Text(
'Geser ke bawah untuk mengabaikan',
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
),
],
),
),
),
);
}
Widget _buildStaleRequest(ChatRequestStaleData data) {
final message = switch (data.reason) {
StaleReason.cancelledByCustomer => 'Permintaan dibatalkan oleh customer',
StaleReason.acceptedByOther => 'Permintaan diterima oleh Bestie lain',
StaleReason.expired => 'Permintaan kedaluwarsa',
};
final icon = switch (data.reason) {
StaleReason.cancelledByCustomer => Icons.cancel_outlined,
StaleReason.acceptedByOther => Icons.people_outline,
StaleReason.expired => Icons.timer_off_outlined,
};
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
Icon(icon, size: 48, color: Colors.orange),
const SizedBox(height: 12),
Text(
message,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ref.read(chatRequestProvider.notifier).acknowledgeStale();
},
child: const Text('OK'),
),
),
],
),
),
const SizedBox(height: HaloSpacing.s16),
// Countdown line — color shifts to danger when <=10s.
Text(
'$remaining detik untuk respon',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: urgent ? FontWeight.w600 : FontWeight.w500,
color: countdownColor,
),
),
// Extend variant only: '+N mnt' duration badge.
if (_isExtend && data.durationMinutes != null) ...[
const SizedBox(height: HaloSpacing.s8),
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s12,
vertical: 4,
),
decoration: const BoxDecoration(
color: HaloTokens.accentAmberSoft,
borderRadius: HaloRadius.pill,
),
child: Text(
'+${data.durationMinutes} mnt',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
fontWeight: FontWeight.w700,
color: HaloTokens.accentAmber,
),
),
),
),
],
const SizedBox(height: HaloSpacing.s12),
// Optional details line (mode / customer name not in state yet —
// per the Stage 3 finding, ChatRequestIncomingData doesn't carry
// customer display name. Fall back to generic copy.)
Text(
_isExtend
? 'Klien lama mau lanjutin curhat sama kamu.'
: 'Pesan masuk dari user — siap dengerin?',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
height: 1.4,
),
),
if (isSensitive) ...[
const SizedBox(height: HaloSpacing.s8),
Align(
alignment: Alignment.centerLeft,
child: SensitivityBadge(
sensitivity: data.topicSensitivity,
fontSize: 11,
),
),
],
// Show free-trial / duration meta as a subtle row (new variant only).
if (!_isExtend &&
(data.isFreeTrial == true || data.durationMinutes != null)) ...[
const SizedBox(height: HaloSpacing.s8),
Text(
data.isFreeTrial == true
? 'Durasi: Free Trial'
: 'Durasi: ${data.durationMinutes} menit',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.inkMuted,
),
),
],
const SizedBox(height: HaloSpacing.s20),
// Button row
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: accepting ? null : onDecline,
style: OutlinedButton.styleFrom(
foregroundColor: HaloTokens.inkSoft,
backgroundColor: HaloTokens.brandSofter,
disabledForegroundColor: HaloTokens.inkMuted,
side: BorderSide.none,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: const RoundedRectangleBorder(
borderRadius: HaloRadius.md,
),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
child: const Text('Tolak'),
),
),
const SizedBox(width: HaloSpacing.s8),
Expanded(
flex: 2,
child: _AcceptButton(
label: _acceptLabel,
accent: _accent,
accenting: accepting,
onPressed: accepting ? null : onAccept,
sensitivityTint:
isSensitive ? sensitivityTheme.badgeBg : null,
),
),
],
),
],
),
);
}
}
/// Filled accept button. Renders pink (HaloTokens.brand) for the new variant
/// and amber (HaloTokens.accentAmber) for the extend variant — same pattern
/// as `_PrimaryAmberButton` in `undangan_screen.dart`. Kept inline rather
/// than extending HaloButton, since adding a `backgroundColor` override to
/// HaloButton would touch every existing call site.
class _AcceptButton extends StatelessWidget {
const _AcceptButton({
required this.label,
required this.accent,
required this.accenting,
required this.onPressed,
required this.sensitivityTint,
});
final String label;
final Color accent;
final bool accenting;
final VoidCallback? onPressed;
final Color? sensitivityTint;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: accent,
foregroundColor: Colors.white,
disabledBackgroundColor: accent.withValues(alpha: 0.5),
disabledForegroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.md),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
child: accenting
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(label),
);
}
}
/// In-flight accept state — minimal card with spinner. The notifier drops
/// the previous incoming data on transition to `ChatRequestAcceptingData`,
/// so we render a small placeholder rather than mirror the full card.
class _AcceptingCard extends StatelessWidget {
const _AcceptingCard();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.brand, width: 2),
boxShadow: HaloShadows.card,
),
padding: const EdgeInsets.all(HaloSpacing.s24),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(HaloTokens.brand),
),
),
SizedBox(width: HaloSpacing.s12),
Flexible(
child: Text(
'Menerima permintaan...',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
),
],
),
);
}
}
/// Restyled stale card for non-race stale reasons (cancelled / expired).
/// The `acceptedByOther` race is handled via snackbar in the listener, so it
/// never reaches this widget.
class _StaleCard extends StatelessWidget {
const _StaleCard({required this.data, required this.onAck});
final ChatRequestStaleData data;
final VoidCallback onAck;
@override
Widget build(BuildContext context) {
final message = switch (data.reason) {
StaleReason.cancelledByCustomer => 'Permintaan dibatalkan oleh klien',
StaleReason.acceptedByOther => 'Permintaan diterima bestie lain',
StaleReason.expired => 'Permintaan kedaluwarsa',
};
return Container(
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border, width: 1),
boxShadow: HaloShadows.card,
),
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'',
style: TextStyle(fontSize: 32),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s12),
Text(
message,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 17,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s20),
ElevatedButton(
onPressed: onAck,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.brand,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: const RoundedRectangleBorder(
borderRadius: HaloRadius.md,
),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
child: const Text('OK'),
),
],
),
);
}