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

@@ -73,6 +73,33 @@ class ChatRequestErrorData extends ChatRequestData {
const ChatRequestErrorData(this.message);
}
/// Plain data holder for the Undangan list. The notifier exposes a derived
/// `List<PendingInvite>` (currently-displayed incoming + queued) so screens
/// outside the popup overlay (the Curhat-Baru tab) can render the same set
/// of pending requests.
///
/// Stage 3 (2026-05-20): added to back the new `UndanganScreen` —
/// `_pendingQueue` itself stays private.
class PendingInvite {
final String sessionId;
final int? durationMinutes;
final bool? isFreeTrial;
final TopicSensitivity topicSensitivity;
final DateTime? createdAt;
final PairingRequestType requestType;
final int? confirmationTimeoutSeconds;
const PendingInvite({
required this.sessionId,
this.durationMinutes,
this.isFreeTrial,
this.topicSensitivity = TopicSensitivity.regular,
this.createdAt,
this.requestType = PairingRequestType.general,
this.confirmationTimeoutSeconds,
});
}
@Riverpod(keepAlive: true)
class ChatRequest extends _$ChatRequest {
WebSocketChannel? _channel;
@@ -87,6 +114,46 @@ class ChatRequest extends _$ChatRequest {
return current + _pendingQueue.length;
}
/// Derived list of all pending invitations for list-style UIs (the
/// Undangan tab). Combines the currently-displayed `ChatRequestIncomingData`
/// (if any) plus every queued request, in display order.
///
/// Pure read view of `_pendingQueue` + `state` — no mutation, no async.
/// Recomputed on every call so the result reflects the latest state at
/// the moment of the call. Riverpod consumers must still `ref.watch`
/// `chatRequestProvider` for rebuilds.
List<PendingInvite> get pendingInvites {
final out = <PendingInvite>[];
final s = state;
if (s is ChatRequestIncomingData) {
out.add(PendingInvite(
sessionId: s.sessionId,
durationMinutes: s.durationMinutes,
isFreeTrial: s.isFreeTrial,
topicSensitivity: s.topicSensitivity,
createdAt: s.createdAt,
requestType: s.requestType,
confirmationTimeoutSeconds: s.confirmationTimeoutSeconds,
));
}
for (final q in _pendingQueue) {
out.add(PendingInvite(
sessionId: q['session_id'] as String,
durationMinutes: q['duration_minutes'] as int?,
isFreeTrial: q['is_free_trial'] as bool?,
topicSensitivity:
TopicSensitivity.fromString(q['topic_sensitivity'] as String?),
createdAt: q['created_at'] != null
? DateTime.tryParse(q['created_at'] as String)
: null,
requestType:
PairingRequestType.fromString(q['request_type'] as String?),
confirmationTimeoutSeconds: q['confirmation_timeout_seconds'] as int?,
));
}
return out;
}
@override
ChatRequestData build() => const ChatRequestIdleData();

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

View File

@@ -33,6 +33,12 @@ class HaloTokens {
static const Color danger = Color(0xFFD86B6B);
static const Color border = Color(0xFFF0E4E8);
// Amber accent — used by the "Perpanjang Curhat" tab (BestieInvitesExtend
// in figma-bestie/project/screens/v5.jsx).
static const Color accentAmber = Color(0xFFD97706);
static const Color accentAmberSoft = Color(0xFFFFE3A8);
static const Color accentAmberBg = Color(0xFFFBEFE8);
// Font family names — must match the `family:` entries in pubspec.yaml.
// Falls back to system fonts when the .ttf assets are not bundled.
static const String fontDisplay = 'BricolageGrotesque';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
enum HaloButtonVariant { primary, secondary, ghost }
enum HaloButtonVariant { primary, secondary, ghost, soft, dark }
enum HaloButtonSize { sm, md, lg }
@@ -93,6 +93,52 @@ class HaloButton extends StatelessWidget {
child: child,
);
break;
case HaloButtonVariant.soft:
button = ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.brandSofter,
foregroundColor: HaloTokens.brandDark,
disabledBackgroundColor: HaloTokens.brandSoft,
disabledForegroundColor: HaloTokens.inkMuted,
elevation: 0,
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
);
break;
case HaloButtonVariant.dark:
button = Container(
decoration: disabled
? null
: const BoxDecoration(
borderRadius: HaloRadius.pill,
boxShadow: [
BoxShadow(
color: Color(0x402A1820),
offset: Offset(0, 6),
blurRadius: 18,
),
],
),
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.ink,
foregroundColor: Colors.white,
disabledBackgroundColor: HaloTokens.inkMuted,
disabledForegroundColor: Colors.white70,
elevation: 0,
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
),
);
break;
}
if (fullWidth) {

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
/// Decorative gradient blob avatar — the "Bestie" abstract avatar (not a face).
///
/// Ported from `mitra_app/figma-bestie/project/screens/primitives.jsx` (HBOrb).
/// The JSX renders a CSS `radial-gradient` plus two soft white highlight blobs
/// stacked over it. In Flutter we approximate with a `Stack`:
/// - base: `Container` w/ `RadialGradient` from top-left
/// - overlay 1: large soft white blob in upper-left (primary specular)
/// - overlay 2: smaller dimmer blob in lower-right (secondary highlight,
/// opposite the JSX which is also lower-right but very faint — keeping
/// the same position for visual parity)
/// - plus an outer drop shadow tinted by the seed's primary color.
///
/// Approximation note: CSS `filter: blur(6px)` on a sibling layer is emulated
/// with low-opacity white circles. It reads as "soft highlight" at a glance
/// without needing `ImageFilter.blur` (which would require ClipOval + BackdropFilter).
class HaloOrb extends StatelessWidget {
const HaloOrb({
super.key,
this.size = 120,
this.seed = 0,
});
/// Diameter in logical pixels.
final double size;
/// 04 selects a deterministic color pair from the warm palette seeds.
/// Out-of-range values are folded with modulo.
final int seed;
/// Warm-palette seed table — mirrors `HBOrb` colors in primitives.jsx:6873.
static const List<List<Color>> _seeds = [
[HaloTokens.brand, HaloTokens.accent],
[HaloTokens.brandDark, HaloTokens.lilac],
[HaloTokens.accent, HaloTokens.brand],
[HaloTokens.lilac, HaloTokens.brand],
[HaloTokens.mint, HaloTokens.brand],
];
@override
Widget build(BuildContext context) {
final pair = _seeds[seed.abs() % _seeds.length];
final primary = pair[0];
final secondary = pair[1];
return SizedBox(
width: size,
height: size,
child: Stack(
children: [
// Base radial gradient + drop shadow.
Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: const Alignment(-0.4, -0.4), // 30% / 30% in JSX
radius: 0.85,
colors: [primary, secondary],
stops: const [0.0, 0.7],
),
boxShadow: [
BoxShadow(
color: primary.withValues(alpha: 0.25),
offset: const Offset(0, 8),
blurRadius: 24,
),
],
),
),
// Inner shadow approximation — darker rim, bottom-right.
// Re-used the JSX `inset -8px -8px 20px rgba(0,0,0,0.12)` intent.
IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: const Alignment(0.6, 0.6),
radius: 0.85,
colors: [
Colors.black.withValues(alpha: 0.0),
Colors.black.withValues(alpha: 0.12),
],
stops: const [0.7, 1.0],
),
),
),
),
// Top-left specular highlight (larger, brighter).
Positioned(
top: size * 0.12,
left: size * 0.20,
child: Container(
width: size * 0.32,
height: size * 0.24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.55),
),
),
),
// Bottom-right faint glint.
Positioned(
bottom: size * 0.14,
right: size * 0.18,
child: Container(
width: size * 0.18,
height: size * 0.14,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.25),
),
),
),
],
),
);
}
}

View File

@@ -1 +1,2 @@
export 'halo_button.dart';
export 'halo_orb.dart';

View File

@@ -33,6 +33,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
int _lockoutSeconds = 0;
Timer? _lockoutTimer;
// Set true in _submit() right before requestOtp; cleared after the listener
// pushes /otp. Without this flag the listener fires on every subsequent
// auth-state transition (verifyOtp's AsyncLoading / AsyncError preserve the
// OtpSentData via Riverpod's copyWithPrevious) and stacks duplicate /otp
// pages on top of itself, because GoRouterState.of(context) returns the
// LoginScreen's own page state (/login), not the navigator's top route.
bool _expectOtpPush = false;
@override
void initState() {
super.initState();
@@ -46,18 +54,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
(prev, next) async {
if (!mounted) return;
final data = next.valueOrNull;
// Push to /otp only when the *current top route* is /login. This
// protects against the OtpScreen's resend stacking a second /otp on
// top of itself (login_screen's listener stays alive on the nav stack
// and would otherwise fire on every fresh MitraAuthOtpSentData).
if (data is MitraAuthOtpSentData) {
final location = GoRouterState.of(context).matchedLocation;
if (location == '/login') {
context.push('/otp', extra: _e164Phone());
}
if (data is MitraAuthOtpSentData && _expectOtpPush) {
_expectOtpPush = false;
context.push('/otp', extra: _e164Phone());
return;
}
if (next is! AsyncError) return;
// Only handle errors for our own requestOtp call. verifyOtp errors
// belong to OtpScreen — without this gate LoginScreen's default
// snackbar would paint on top of OtpScreen's inline error.
if (!_expectOtpPush) return;
_expectOtpPush = false;
final err = next.error;
if (err is! MitraAuthError) {
@@ -154,6 +161,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
Future<void> _submit() {
final phone = _e164Phone();
setState(() => _phoneErrorText = null);
_expectOtpPush = true;
return ref.read(mitraAuthProvider.notifier).requestOtp(phone);
}

View File

@@ -8,14 +8,16 @@ import '../../../core/chat/sensitivity_config_provider.dart';
import '../../../core/chat/widgets/sensitivity_badge.dart';
import '../../../core/chat/widgets/sensitivity_theme.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_orb.dart';
// Chat theme colors
const _kUserBubbleColor = Color(0xFFD4929A);
const _kBannerColor = Color(0xFFC4868F);
const _kAccentPink = Color(0xFFBE7C8A);
// Phase 4 — voice-call mode badge background. Mirrors `HaloTokens.accent`
// from the customer app palette so both apps render the same pill color.
const _kVoiceCallPillColor = Color(0xFFF7B26A);
// Mitra bubble gradient — matches `BestieChatV5` (figma-bestie/.../v5.jsx:282)
// pink → purple direction (135deg ≈ topLeft → bottomRight).
const _kMitraBubbleGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFC44979), Color(0xFF8B5CF6)],
);
class MitraChatScreen extends ConsumerStatefulWidget {
final String sessionId;
@@ -93,7 +95,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
// Parent build runs ONCE per lifecycle — there are no ref.watch calls here.
// State changes (messages, typing, status updates, mode flip, sensitivity
// flip) all rebuild only the leaf consumers that watch them:
// - _MitraChatVoicePill → mode flag (via .select)
// - _MitraChatSubtitle → mode + sessionExpired (via .select)
// - _MitraChatTopicToggle → topicSensitivity (via .select) + config
// - _MitraChatTimerAction → mitraChatRemainingSecondsProvider
// - _MitraChatBodyContent → full chatProvider + extensionProvider
@@ -117,27 +119,48 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
});
// Orb seed derived from the sessionId — stable per session, identical
// to the convention used by `undangan_screen.dart` for incoming requests.
// We don't have a separate customerId in this screen scope (only name +
// sessionId), and the spec explicitly allows hashing sessionId here.
final orbSeed = widget.sessionId.hashCode;
return Scaffold(
backgroundColor: HaloTokens.bg,
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: HaloTokens.surface,
foregroundColor: HaloTokens.ink,
elevation: 0.5,
centerTitle: true,
centerTitle: false,
titleSpacing: 0,
leading: IconButton(
icon: const Icon(Icons.chevron_left, size: 28),
icon: const Icon(Icons.chevron_left, size: 28, color: HaloTokens.ink),
onPressed: () => context.pop(),
),
title: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
HaloOrb(size: 38, seed: orbSeed),
const SizedBox(width: 10),
Flexible(
child: Text(
widget.customerName,
overflow: TextOverflow.ellipsis,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.customerName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
height: 1.2,
),
),
const _MitraChatSubtitle(),
],
),
),
const _MitraChatVoicePill(),
],
),
actions: [
@@ -158,45 +181,36 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
}
/// AppBar voice-call mode badge. Watches only the `mode` field of the chat
/// state — the conditional collapses to a bool via `.select`, so this widget
/// rebuilds only when the mode actually flips (essentially never during a
/// session) and the surrounding AppBar stays still on every message/typing
/// state change.
class _MitraChatVoicePill extends ConsumerWidget {
const _MitraChatVoicePill();
/// AppBar subtitle line. Watches only the `mode` flag and `sessionExpired`
/// flag (each via `.select`), so a WS message / typing / status update never
/// rebuilds this widget — only an actual mode flip or expiry transition does.
/// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:239): "sesi aktif · Chat",
/// "sesi aktif · Voice", or "sesi berakhir" (color #A8410E when ended).
class _MitraChatSubtitle extends ConsumerWidget {
const _MitraChatSubtitle();
static const _endedInk = Color(0xFFA8410E);
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCall = ref.watch(mitraChatProvider.select(
(s) => s is MitraChatConnectedData && s.mode == SessionMode.call,
));
if (!isCall) return const SizedBox.shrink();
return const Padding(
padding: EdgeInsets.only(left: 8),
child: _VoiceCallPillBody(),
);
}
}
class _VoiceCallPillBody extends StatelessWidget {
const _VoiceCallPillBody();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: const BoxDecoration(
color: _kVoiceCallPillColor,
borderRadius: BorderRadius.all(Radius.circular(9999)),
),
child: const Text(
'📞 Voice Call',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
final ended = ref.watch(mitraChatProvider.select(
(s) => s is MitraChatConnectedData && s.sessionExpired,
));
final text = ended
? 'sesi berakhir'
: isCall
? 'sesi aktif · Voice'
: 'sesi aktif · Chat';
return Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: ended ? _endedInk : HaloTokens.inkSoft,
height: 1.2,
),
);
}
@@ -331,9 +345,6 @@ class _MitraChatBodyContent extends ConsumerStatefulWidget {
}
class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
bool _showBestieBanner = true;
bool _showUserBanner = true;
// Phase 4 ESP topic display labels. Mirrors the customer-side `EspTopic`
// enum's `label` property — we only need to read these here, not write.
static const Map<String, String> _espTopicLabels = {
@@ -422,70 +433,92 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
return _buildAwaitingCustomerGoodbyeView(state);
}
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
// Background: flat cream (HaloTokens.bg) per Figma `BestieChatV5`. Drop
// the previous `chat_pattern.png` wallpaper layer. Sensitivity tint still
// applies for the sensitive case — it's a working feature; for regular
// sessions we use the flat brand bg so the cream/Figma look comes through.
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
final bg = isSensitive
? SensitivityTheme.sensitive.bgTint
: HaloTokens.bg;
// Build list payload: leading "sesi dimulai" system pill, optional ESP
// topic chip row, then the message bubbles. Single source of truth for
// index → item resolution so the ListView builder stays simple.
final hasTopics = state.topics.isNotEmpty;
// [0]=system pill, [1]=topics (optional), then messages.
final leadingCount = 1 + (hasTopics ? 1 : 0);
return Stack(
children: [
// Background pattern
Positioned.fill(
child: Container(
color: bgTint,
child: Image.asset(
'assets/images/chat_pattern.png',
repeat: ImageRepeat.repeat,
fit: BoxFit.none,
return Container(
color: bg,
child: Column(
children: [
// Session-ended banner (mirrors `BestieChatV5` ended-state notice in
// figma-bestie/.../v5.jsx:294). Pinned directly under the header so
// it's visible regardless of scroll position.
if (state.sessionExpired) _buildSessionEndedBanner(),
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
itemCount: state.messages.length + leadingCount,
itemBuilder: (context, index) {
if (index == 0) return _buildSystemPill('sesi dimulai');
if (hasTopics && index == 1) {
return _buildTopicChipsRow(state.topics);
}
final msg = state.messages[index - leadingCount];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
),
// Typing indicator
if (state.isOtherTyping)
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 6),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Customer sedang mengetik...',
style: TextStyle(color: HaloTokens.inkMuted, fontSize: 12),
),
),
),
// Input bar — softer disabled-state notice once the timer hits
// zero. Tunggu klien perpanjang / tutup obrolan; we don't accept
// new mitra messages on an expired session.
if (state.sessionExpired)
_buildEndedInputNotice()
else
_buildInputBar(),
],
),
);
}
/// Centered system-message pill. Replaces the legacy bracketed entry
/// banners. Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:255).
Widget _buildSystemPill(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Align(
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.pill,
),
child: Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: HaloTokens.brand,
),
),
),
// 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 — when the customer picked ESP topics during
// onboarding, render a read-only chip row as the first list
// item (above the first message bubble). Info-only.
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length +
(state.topics.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (state.topics.isNotEmpty && index == 0) {
return _buildTopicChipsRow(state.topics);
}
final msgIndex =
state.topics.isNotEmpty ? index - 1 : index;
final msg = state.messages[msgIndex];
final isMe = msg.senderType == UserType.mitra;
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('Customer sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
),
),
// Input bar
_buildInputBar(),
],
),
],
),
);
}
@@ -500,15 +533,15 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFE0CDD1)),
color: HaloTokens.surface,
borderRadius: HaloRadius.pill,
border: Border.all(color: HaloTokens.border),
),
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: _kAccentPink,
color: HaloTokens.brandDark,
fontWeight: FontWeight.w500,
),
),
@@ -518,56 +551,128 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
);
}
Widget _buildEntryBanner(String text, VoidCallback onDismiss) {
Widget _buildSessionEndedBanner() {
const bg = Color(0xFFFFE5E5);
const border = Color(0xFFF5B5B5);
const ink = Color(0xFF7A2828);
return Container(
color: _kBannerColor,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: const BoxDecoration(
color: bg,
border: Border(bottom: BorderSide(color: border)),
),
child: const Row(
children: [
const Icon(Icons.volume_up, color: Colors.white, size: 18),
const SizedBox(width: 8),
Icon(Icons.access_time, color: ink, size: 18),
SizedBox(width: 10),
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),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Durasi sesi habis. ',
style: TextStyle(fontWeight: FontWeight.w700, color: ink),
),
TextSpan(
text: 'Tunggu klien perpanjang atau tutup obrolan.',
style: TextStyle(color: ink),
),
],
),
style: TextStyle(fontSize: 12.5, height: 1.4),
),
),
],
),
);
}
Widget _buildEndedInputNotice() {
return SafeArea(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: const Text(
'Sesi sudah berakhir 💛',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: HaloTokens.inkSoft,
fontWeight: FontWeight.w500,
),
),
),
);
}
// TODO(stage7): reply-quote bubble polish. Figma `BestieChatV5` renders a
// quote block above the bubble text when a message replies to another
// (2px left accent stripe, original sender name, truncated original text).
// Out of scope for Stage 6 — the `MitraChatMessage` model in
// `core/chat/mitra_chat_notifier.dart` does not carry a `replyTo` payload
// and the backend WS frames don't ship one. Adding it requires changes to
// backend message schema + this notifier + ChatService. Until then there
// are no reply-to messages to render. Corner-radius logic below already
// accepts the future `replyTo` case (top corner becomes 4 when replying).
Widget _buildMessageBubble(MitraChatMessage msg, bool isMe) {
// Tail corner: 4 on the side facing the sender's edge (bottom-right for
// me, bottom-left for them). In stage7 the top corner will also become
// 4 when this message replies to another — see TODO(stage7) above.
final radius = BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 4),
bottomRight: Radius.circular(isMe ? 4 : 16),
);
final maxW = MediaQuery.of(context).size.width * 0.78;
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),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxW),
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: isMe ? _kMitraBubbleGradient : null,
color: isMe ? null : HaloTokens.surface,
border: isMe ? null : Border.all(color: HaloTokens.border),
borderRadius: radius,
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(msg.status),
child: Text(
msg.content,
style: TextStyle(
fontSize: 13.5,
height: 1.45,
color: isMe ? Colors.white : HaloTokens.ink,
),
),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 10, color: HaloTokens.inkMuted),
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(msg.status),
],
],
],
),
],
),
],
),
),
),
);
@@ -576,56 +681,96 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
Widget _buildStatusIcon(String status) {
switch (status) {
case 'sending':
return const Icon(Icons.access_time, size: 14, color: Colors.white70);
return const Icon(Icons.access_time, size: 12, color: HaloTokens.inkMuted);
case MessageStatus.sent:
return const Icon(Icons.check, size: 14, color: Colors.white70);
return const Icon(Icons.check, size: 12, color: HaloTokens.inkMuted);
case MessageStatus.delivered:
return const Icon(Icons.done_all, size: 14, color: Colors.white70);
return const Icon(Icons.done_all, size: 12, color: HaloTokens.inkMuted);
case MessageStatus.read:
return const Icon(Icons.done_all, size: 14, color: Colors.white);
return const Icon(Icons.done_all, size: 12, color: HaloTokens.brand);
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: widget.messageController,
onChanged: widget.onTextChanged,
textInputAction: TextInputAction.send,
onSubmitted: (_) => widget.onSend(),
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,
// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:305): cream-bg pill text
// field with a round pink send button. SafeArea takes care of the home-
// indicator inset; the JSX uses a hardcoded 30px bottom pad — we let
// SafeArea handle it instead so devices without a home indicator don't
// get a giant gap.
return Container(
decoration: const BoxDecoration(
color: HaloTokens.surface,
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
// Both children rendered inside a Container(height: 44) so their
// visible heights match exactly. Earlier attempt used
// OutlineInputBorder on the TextField directly, but OutlineInputBorder
// sizes to text content (not the parent SizedBox), so the pill came
// out at ~29dp while the round button was 44dp — visual centers
// drifted ~43px apart. This pattern is bulletproof: a Container
// draws the pill outline + bg, a borderless centered TextField sits
// inside, and the send button is the same explicit 44dp Container.
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
color: HaloTokens.bg,
borderRadius: HaloRadius.pill,
border: Border.fromBorderSide(
BorderSide(color: HaloTokens.border),
),
),
child: TextField(
controller: widget.messageController,
onChanged: widget.onTextChanged,
textInputAction: TextInputAction.send,
onSubmitted: (_) => widget.onSend(),
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(fontSize: 13.5, color: HaloTokens.ink),
decoration: const InputDecoration(
hintText: 'ketik balasan...',
hintStyle: TextStyle(color: HaloTokens.inkMuted, fontSize: 13.5),
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
),
),
const SizedBox(width: 8),
Container(
decoration: const BoxDecoration(
color: _kAccentPink,
shape: BoxShape.circle,
const SizedBox(width: 8),
Container(
width: 44,
height: 44,
decoration: const BoxDecoration(
color: HaloTokens.brand,
shape: BoxShape.circle,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: widget.onSend,
child: const Center(
child: Icon(Icons.arrow_upward, color: Colors.white, size: 18),
),
),
),
),
child: IconButton(
icon: const Icon(Icons.send, color: Colors.white, size: 20),
onPressed: widget.onSend,
),
),
],
],
),
),
),
);
@@ -748,53 +893,45 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
}
Widget _buildAwaitingCustomerGoodbyeView(MitraChatConnectedData state) {
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
return Stack(
children: [
Positioned.fill(
child: Container(
color: bgTint,
child: Image.asset(
'assets/images/chat_pattern.png',
repeat: ImageRepeat.repeat,
fit: BoxFit.none,
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
final bg = isSensitive
? SensitivityTheme.sensitive.bgTint
: HaloTokens.bg;
return Container(
color: bg,
child: Column(
children: [
Container(
width: double.infinity,
color: Colors.amber.shade100,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Pesan penutupmu sudah terkirim. Menunggu user...',
style: TextStyle(color: Colors.amber.shade900, fontWeight: FontWeight.w600),
),
),
],
),
),
),
Column(
children: [
Container(
width: double.infinity,
color: Colors.amber.shade100,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Pesan penutupmu sudah terkirim. Menunggu user...',
style: TextStyle(color: Colors.amber.shade900, fontWeight: FontWeight.w600),
),
),
],
),
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
Expanded(
child: ListView.builder(
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length,
itemBuilder: (context, index) {
final msg = state.messages[index];
final isMe = msg.senderType == UserType.mitra;
return _buildMessageBubble(msg, isMe);
},
),
),
],
),
],
),
],
),
);
}
}
@@ -804,21 +941,74 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
/// rebuilds *only* this widget (a single Text), not the surrounding AppBar,
/// Scaffold body, message ListView, or input bar. This is the per-second
/// hotspot the wider chat-screen perf work targets.
///
/// Visual mirrors `BestieChatV5` (figma-bestie/.../v5.jsx):
/// - active → amber pill "SISA WAKTU\nmm:ss" (#FFE8D9 bg / #FFCAA0 border)
/// - ended → red pill "SELESAI\n00:00" (#FFE5E5 bg / #F5B5B5 border)
/// "Ended" here is `seconds <= 0`. The session-expired flag lives on
/// `mitraChatProvider` but reading it here would re-couple this widget to
/// the chat state and defeat the per-second perf isolation — so we use the
/// timer reaching zero as a proxy. The red banner below the header uses the
/// authoritative `sessionExpired` flag.
class _MitraChatTimerAction extends ConsumerWidget {
const _MitraChatTimerAction();
static const _activeBg = Color(0xFFFFE8D9);
static const _activeBorder = Color(0xFFFFCAA0);
static const _activeInk = Color(0xFF7A3E08);
static const _endedBg = Color(0xFFFFE5E5);
static const _endedBorder = Color(0xFFF5B5B5);
static const _endedInk = Color(0xFF7A2828);
@override
Widget build(BuildContext context, WidgetRef ref) {
final seconds = ref.watch(mitraChatRemainingSecondsProvider);
if (seconds == null) return const SizedBox.shrink();
// When the first session_timer frame hasn't arrived yet (`seconds == null`)
// show a placeholder pill — matches the Figma which always renders the
// pill — instead of disappearing. Treat null as "loading", not "ended".
final loading = seconds == null;
final ended = !loading && seconds <= 0;
final mm = loading ? '--' : (seconds ~/ 60).clamp(0, 99).toString().padLeft(2, '0');
final ss = loading ? '--' : (seconds % 60).clamp(0, 59).toString().padLeft(2, '0');
final label = ended ? 'SELESAI' : 'SISA WAKTU';
final value = ended ? '00:00' : '$mm:$ss';
final bg = ended ? _endedBg : _activeBg;
final border = ended ? _endedBorder : _activeBorder;
final ink = ended ? _endedInk : _activeInk;
return Padding(
padding: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.only(right: 12),
child: Center(
child: Text(
'${seconds}s',
style: TextStyle(
color: seconds < 30 ? Colors.red : Colors.black,
fontWeight: FontWeight.bold,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: HaloRadius.sm,
border: Border.all(color: border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: ink,
),
),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: ink,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
),

View File

@@ -6,10 +6,15 @@ import '../../core/status/status_notifier.dart';
import '../../core/chat/chat_request_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../../core/theme/widgets/widgets.dart';
import '../undangan/undangan_screen.dart' show undanganTabProvider;
/// Bestie Home (mitra). Mirrors `figma-bestie/project/screens/v4.jsx::BestieHome`
/// + `v5.jsx::BestieHomeOffline`. Bottom nav (BestieTabBar) is deferred until
/// the Profil + Chat tabs have screen implementations.
/// Bestie Home (mitra). Mirrors
/// `figma-bestie/project/screens/v4.jsx::BestieHome` (online variant) +
/// `figma-bestie/project/screens/v5.jsx::BestieHomeOffline` (offline variant).
///
/// Lives inside the Home branch of the shell (`router.dart`), so the
/// `BestieTabBar` is rendered by `ShellScreen` — this screen owns body
/// content only.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@@ -24,7 +29,7 @@ class HomeScreen extends ConsumerWidget {
final statusState = ref.watch(onlineStatusProvider);
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
// Load pending requests if mitra is already online (existing logic).
// Boot chat-request listener whenever mitra is online (existing logic).
if (statusState is StatusLoadedData && statusState.isOnline) {
final requestState = ref.watch(chatRequestProvider);
if (requestState is ChatRequestIdleData) {
@@ -47,116 +52,135 @@ class HomeScreen extends ConsumerWidget {
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(displayName: displayName, isOnline: isOnline),
const SizedBox(height: 18),
const _TilesGrid(),
const SizedBox(height: 14),
_StatusCard(isOnline: isOnline),
const SizedBox(height: 10),
const _GantiStatusButton(),
const SizedBox(height: 22),
const _Pengingat(),
const SizedBox(height: 16),
// Functional shortcuts (no figma equivalent — kept until the
// Chat tab is built so the user can still reach sessions /
// history pages from home).
const _ShortcutTile(
icon: Icons.chat_bubble_outline,
title: 'Sesi Aktif',
route: '/sessions',
),
const SizedBox(height: 8),
const _ShortcutTile(
icon: Icons.history,
title: 'Riwayat Chat',
route: '/chat/history',
),
],
),
),
child: isOnline
? _OnlineHome(displayName: displayName)
: _OfflineHome(displayName: displayName),
),
);
}
}
class _Header extends ConsumerWidget {
// ─── Online variant — matches v4.jsx:417 ──────────────────────────────
class _OnlineHome extends StatelessWidget {
final String displayName;
const _OnlineHome({required this.displayName});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(displayName: displayName, isOnline: true),
const SizedBox(height: 18),
const _TilesGrid(),
const SizedBox(height: 14),
const _StatusCard(isOnline: true),
const SizedBox(height: 10),
const _GantiStatusButton(),
const SizedBox(height: 22),
const _Pengingat(),
],
),
);
}
}
// ─── Offline variant — matches v5.jsx:188 ─────────────────────────────
class _OfflineHome extends StatelessWidget {
final String displayName;
const _OfflineHome({required this.displayName});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(displayName: displayName, isOnline: false),
const SizedBox(height: 28),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFFFCE8E8),
borderRadius: HaloRadius.lg,
border: Border.all(color: const Color(0xFFF5B5B5), width: 1.5),
),
child: const Column(
children: [
Text('😴', style: TextStyle(fontSize: 44)),
SizedBox(height: 10),
Text(
'Kamu lagi OFFLINE',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 17,
fontWeight: FontWeight.w700,
color: Color(0xFF7A2828),
),
),
SizedBox(height: 10),
SizedBox(
width: 260,
child: Text(
'Gak terima curhat dulu. Istirahat dulu ya — nyalain ONLINE kalo udah siap lagi 💛',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
height: 1.5,
color: Color(0xFF9C4040),
),
),
),
],
),
),
const SizedBox(height: 16),
const _GantiStatusButton(offlineLabel: 'Nyalain Status (Online)'),
],
),
);
}
}
/// Home header — greeting block. Logout moved to Profil tab in Stage 4
/// (was a `more_horiz` bottom-sheet menu here pre-Stage-4).
class _Header extends StatelessWidget {
final String displayName;
final bool isOnline;
const _Header({required this.displayName, required this.isOnline});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final greetingSuffix = isOnline ? '🌸' : '🌙';
return Row(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Hei,',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
),
Text(
'Bestie $displayName $greetingSuffix',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.4,
),
),
],
const Text(
'Hei,',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
),
IconButton(
icon: const Icon(Icons.more_horiz, color: HaloTokens.ink),
style: IconButton.styleFrom(
backgroundColor: HaloTokens.surface,
shape: const CircleBorder(),
Text(
'Bestie $displayName $greetingSuffix',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.4,
),
onPressed: () => _showMenu(context, ref),
),
],
);
}
Future<void> _showMenu(BuildContext context, WidgetRef ref) {
return showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.logout, color: HaloTokens.danger),
title: const Text(
'Keluar',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontWeight: FontWeight.w600,
),
),
onTap: () async {
Navigator.of(ctx).pop();
await ref.read(mitraAuthProvider.notifier).logout();
},
),
],
),
),
);
}
}
class _TilesGrid extends ConsumerWidget {
@@ -167,7 +191,11 @@ class _TilesGrid extends ConsumerWidget {
ref.watch(chatRequestProvider);
final undanganCount =
ref.read(chatRequestProvider.notifier).activeRequestCount;
final shell = StatefulNavigationShell.of(context);
// Both tiles route to the Chat tab's Undangan screen, differing only in
// which sub-tab they pre-select via `undanganTabProvider`. Tiles must
// stay tappable even at count=0 (destination shows its empty state).
return Row(
children: [
Expanded(
@@ -177,21 +205,28 @@ class _TilesGrid extends ConsumerWidget {
subtitle:
undanganCount > 0 ? 'Menunggu: $undanganCount' : 'Belum ada',
badgeCount: undanganCount,
onTap: () => context.push('/chat/requests/history'),
onTap: () {
ref.read(undanganTabProvider.notifier).state = 0;
shell.goBranch(1);
},
),
),
const SizedBox(width: 10),
// Perpanjang tile — backend wiring (extension request count) isn't
// exposed to the home yet, so render the static "Belum ada" state to
// match the figma. Wire to the same notifier once an extension-count
// provider exists.
const Expanded(
// Perpanjang tile — `MitraExtension` notifier holds per-flow state
// only (idle/responding/...), no pending-list. Keep static "Belum ada"
// until backend exposes a pending-extension count.
// TODO(stage-2): wire extension count when extension_notifier
// exposes it (or once a dedicated pending-extensions provider exists).
Expanded(
child: _DarkTile(
icon: '',
label: 'Perpanjang',
subtitle: 'Belum ada',
badgeCount: 0,
onTap: null,
onTap: () {
ref.read(undanganTabProvider.notifier).state = 1;
shell.goBranch(1);
},
),
),
],
@@ -218,8 +253,8 @@ class _DarkTile extends StatelessWidget {
Widget build(BuildContext context) {
final card = Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF2A1820),
decoration: const BoxDecoration(
color: Color(0xFF2A1820),
borderRadius: HaloRadius.lg,
),
child: Stack(
@@ -347,7 +382,11 @@ class _StatusCard extends StatelessWidget {
}
class _GantiStatusButton extends ConsumerWidget {
const _GantiStatusButton();
/// Optional label override for the offline variant (v5.jsx uses
/// "Nyalain Status (Online)"). Defaults to "Ganti Status" for the online
/// variant (v4.jsx).
final String? offlineLabel;
const _GantiStatusButton({this.offlineLabel});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -355,8 +394,12 @@ class _GantiStatusButton extends ConsumerWidget {
final isOnline = statusState is StatusLoadedData && statusState.isOnline;
final isLoading = statusState is StatusLoadingData;
final label = isLoading
? 'memproses...'
: (isOnline ? 'Ganti Status' : (offlineLabel ?? 'Ganti Status'));
return HaloButton(
label: isLoading ? 'memproses...' : 'Ganti Status',
label: label,
fullWidth: true,
onPressed: isLoading
? null
@@ -394,8 +437,8 @@ class _Pengingat extends StatelessWidget {
),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEEE7F5),
decoration: const BoxDecoration(
color: Color(0xFFEEE7F5),
borderRadius: HaloRadius.md,
),
child: const Row(
@@ -432,52 +475,3 @@ class _Pengingat extends StatelessWidget {
);
}
}
class _ShortcutTile extends StatelessWidget {
final IconData icon;
final String title;
final String route;
const _ShortcutTile({
required this.icon,
required this.title,
required this.route,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: HaloRadius.lg,
onTap: () => context.push(route),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
child: Row(
children: [
Icon(icon, color: HaloTokens.brandDark, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
),
const Icon(Icons.chevron_right,
color: HaloTokens.inkMuted, size: 20),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,430 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../../core/theme/widgets/widgets.dart';
/// Bestie Profil tab — mirrors
/// `figma-bestie/project/screens/v5.jsx::BestieProfile`.
///
/// Lives inside branch 2 of the shell (`router.dart`), so `BestieTabBar` is
/// rendered by `ShellScreen` — this screen owns body content only.
///
/// Stage 4 deviation from the JSX: the Figma "Chat WhatsApp Kami / Chat
/// Telegram Kami" rows surface customer-facing admin handles. Mitras are
/// internal-only audience (see project memory `feedback_mitra_internal_audience`),
/// so those two rows are replaced with a single "Hubungi Koordinator" entry
/// pointing at the internal coordinator channel.
class ProfilScreen extends ConsumerWidget {
const ProfilScreen({super.key});
// TODO(stage-4): replace with `PackageInfo.fromPlatform().version` when
// the `package_info_plus` package is added in a future change. Hardcoded
// for now to avoid pulling a new dependency.
static const String _appVersion = '1.0.0';
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(mitraAuthProvider);
final authData = authState.valueOrNull;
final profile = authData is MitraAuthAuthenticatedData ? authData.profile : null;
final displayName = (profile?['display_name'] as String?) ?? 'Bestie';
final phone = (profile?['phone'] as String?) ?? '';
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Header(),
const SizedBox(height: 20),
_ProfileCard(displayName: displayName, phone: phone),
const SizedBox(height: 28),
_MenuList(
onTapCoordinator: () => _snack(
context,
'Hubungi koordinator via grup internal — info lengkap segera tersedia',
),
onTapTerms: () => _snack(context, 'Segera tersedia'),
onTapPrivacy: () => _snack(context, 'Segera tersedia'),
),
const SizedBox(height: 24),
_DangerZone(
onLogout: () => _confirmLogout(context, ref),
onDelete: () => _snack(
context,
'Hubungi koordinator untuk penghapusan akun',
),
),
const SizedBox(height: 16),
const _VersionFooter(version: _appVersion),
],
),
),
),
);
}
void _snack(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
message,
style: const TextStyle(fontFamily: HaloTokens.fontBody),
),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: true,
builder: (ctx) => AlertDialog(
title: const Text(
'Yakin mau keluar?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
content: const Text(
'Kamu bakal sign-out dari akun mitra ini. Login lagi pakai nomor HP yang sama untuk masuk.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
height: 1.4,
color: HaloTokens.inkSoft,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text(
'Batal',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkSoft,
fontWeight: FontWeight.w600,
),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text(
'Keluar',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
if (confirmed == true) {
await ref.read(mitraAuthProvider.notifier).logout();
// Router redirect handles navigation to /login on auth state change.
}
}
}
// ─── Header — centered "Profil" title (no back arrow; tab nav owns nav) ──
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
return const Center(
child: Text(
'Profil',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.4,
),
),
);
}
}
// ─── Profile card — HaloOrb + display name + role + phone ────────────────
class _ProfileCard extends StatelessWidget {
final String displayName;
final String phone;
const _ProfileCard({required this.displayName, required this.phone});
@override
Widget build(BuildContext context) {
// Deterministic seed from phone — same number always gets the same orb.
final seed = phone.isEmpty ? 0 : phone.hashCode;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
boxShadow: HaloShadows.soft,
),
child: Column(
children: [
HaloOrb(size: 96, seed: seed),
const SizedBox(height: 16),
Text(
displayName,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.4,
),
),
const SizedBox(height: 4),
const Text(
'Bestie · Mitra Halo Bestie',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
phone,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontMono,
fontSize: 12.5,
color: HaloTokens.inkMuted,
),
),
],
],
),
);
}
}
// ─── Menu list — 3 stacked _MenuTile items ───────────────────────────────
class _MenuList extends StatelessWidget {
final VoidCallback onTapCoordinator;
final VoidCallback onTapTerms;
final VoidCallback onTapPrivacy;
const _MenuList({
required this.onTapCoordinator,
required this.onTapTerms,
required this.onTapPrivacy,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_MenuTile(
icon: Icons.support_agent_outlined,
label: 'Hubungi Koordinator',
subtitle: 'via grup koordinator internal',
onTap: onTapCoordinator,
),
const SizedBox(height: 10),
_MenuTile(
icon: Icons.description_outlined,
label: 'Syarat & Ketentuan',
onTap: onTapTerms,
),
const SizedBox(height: 10),
_MenuTile(
icon: Icons.lock_outline,
label: 'Kebijakan Privasi',
onTap: onTapPrivacy,
),
],
);
}
}
class _MenuTile extends StatelessWidget {
final IconData icon;
final String label;
final String? subtitle;
final VoidCallback onTap;
const _MenuTile({
required this.icon,
required this.label,
this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.md,
child: InkWell(
borderRadius: HaloRadius.md,
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 56),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.border),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.sm,
),
alignment: Alignment.center,
child: Icon(icon, size: 18, color: HaloTokens.brandDark),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
color: HaloTokens.inkMuted,
),
),
],
],
),
),
const Icon(
Icons.chevron_right,
size: 20,
color: HaloTokens.inkMuted,
),
],
),
),
),
);
}
}
// ─── Danger zone — Keluar (secondary) + Hapus Akun (danger text) ─────────
class _DangerZone extends StatelessWidget {
final VoidCallback onLogout;
final VoidCallback onDelete;
const _DangerZone({required this.onLogout, required this.onDelete});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
HaloButton(
label: 'Keluar',
fullWidth: true,
variant: HaloButtonVariant.secondary,
onPressed: onLogout,
),
const SizedBox(height: 10),
// Hapus Akun — destructive ghost-style with danger border + text.
Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.pill,
child: InkWell(
borderRadius: HaloRadius.pill,
onTap: onDelete,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
borderRadius: HaloRadius.pill,
border: Border.all(
color: HaloTokens.danger.withValues(alpha: 0.4),
),
),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.delete_outline,
size: 17,
color: HaloTokens.danger,
),
SizedBox(width: 8),
Text(
'Hapus Akun',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
color: HaloTokens.danger,
),
),
],
),
),
),
),
],
);
}
}
// ─── Version footer ──────────────────────────────────────────────────────
class _VersionFooter extends StatelessWidget {
final String version;
const _VersionFooter({required this.version});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'HaloBestie · v$version',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
color: HaloTokens.inkMuted,
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/chat/chat_request_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import 'widgets/bestie_tab_bar.dart';
/// Shell scaffold for the 3-tab mitra UI: Home / Chat / Profil.
///
/// Used as the `builder` of a `StatefulShellRoute.indexedStack` in
/// `router.dart`. Renders the active branch's navigator in the body and
/// `BestieTabBar` at the bottom.
///
/// Tab content is owned by each branch route — this widget only owns the
/// scaffold + tab bar.
class ShellScreen extends ConsumerWidget {
const ShellScreen({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the chat-request state so the badge updates when new requests
// arrive (or are accepted/declined). We don't use the value directly —
// we want the rebuild trigger, then read the count via the notifier.
ref.watch(chatRequestProvider);
final chatBadge = ref.read(chatRequestProvider.notifier).activeRequestCount;
return Scaffold(
backgroundColor: HaloTokens.bg,
body: navigationShell,
bottomNavigationBar: BestieTabBar(
activeIndex: navigationShell.currentIndex,
chatBadgeCount: chatBadge,
onTap: (i) => navigationShell.goBranch(
i,
// Re-tapping the active tab pops back to the branch root.
initialLocation: i == navigationShell.currentIndex,
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../../core/theme/halo_tokens.dart';
/// Bottom navigation bar for the mitra app shell.
///
/// Mirrors `figma-bestie/project/screens/v4.jsx::BestieTabBar` (v4.jsx:464).
/// Three tabs: Home / Chat / Profil. The Chat tab can render a red badge
/// with [chatBadgeCount] when > 0.
class BestieTabBar extends StatelessWidget {
const BestieTabBar({
super.key,
required this.activeIndex,
required this.onTap,
this.chatBadgeCount = 0,
});
/// 0 = Home, 1 = Chat, 2 = Profil.
final int activeIndex;
final ValueChanged<int> onTap;
final int chatBadgeCount;
static const _items = <_TabItem>[
_TabItem(emoji: '🏠', label: 'Home'),
_TabItem(emoji: '💬', label: 'Chat'),
_TabItem(emoji: '👤', label: 'Profil'),
];
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: HaloTokens.surface,
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: SafeArea(
top: false,
child: SizedBox(
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (var i = 0; i < _items.length; i++)
Expanded(
child: _Tab(
item: _items[i],
active: activeIndex == i,
badgeCount: i == 1 ? chatBadgeCount : 0,
onTap: () => onTap(i),
),
),
],
),
),
),
);
}
}
class _TabItem {
const _TabItem({required this.emoji, required this.label});
final String emoji;
final String label;
}
class _Tab extends StatelessWidget {
const _Tab({
required this.item,
required this.active,
required this.badgeCount,
required this.onTap,
});
final _TabItem item;
final bool active;
final int badgeCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = active ? HaloTokens.brand : HaloTokens.inkMuted;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Text(item.emoji, style: const TextStyle(fontSize: 18, height: 1.1)),
if (badgeCount > 0)
Positioned(
top: -4,
right: -10,
child: Container(
constraints: const BoxConstraints(minWidth: 16),
height: 16,
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Color(0xFFFF4D6A),
borderRadius: HaloRadius.pill,
),
child: Text(
'$badgeCount',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 10,
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
color: color,
),
),
const SizedBox(height: 4),
// Active-indicator pill — small pink underline below label.
Container(
width: 18,
height: 3,
decoration: BoxDecoration(
color: active ? HaloTokens.brand : Colors.transparent,
borderRadius: HaloRadius.pill,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,528 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/chat/chat_request_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../../core/theme/widgets/halo_button.dart';
import '../../core/theme/widgets/halo_orb.dart';
/// Single source of truth for which Undangan sub-tab is currently selected.
/// 0 = Curhat Baru, 1 = Perpanjang Curhat. Home tiles write this before
/// switching to the Chat tab; UndanganScreen reads it on init to position
/// its TabController.
final undanganTabProvider = StateProvider<int>((_) => 0);
/// Undangan (Invitations) screen for the Chat tab of the Bestie shell.
///
/// Mirrors `figma-bestie/project/screens/v4.jsx::BestieInvites` for the
/// Curhat Baru tab and `figma-bestie/project/screens/v5.jsx::BestieInvitesExtend`
/// for the Perpanjang Curhat tab.
///
/// The Curhat Baru tab is wired to `chatRequestProvider` and lists every
/// pending invitation (the popup overlay shows ONE — this screen shows ALL).
/// Accept / Tolak buttons share the same notifier methods as the popup so
/// both surfaces stay in sync.
///
/// The Perpanjang tab is an empty-state placeholder until the backend
/// exposes a queryable stream of pending extension invitations.
///
/// `BestieTabBar` is rendered by the parent `ShellScreen`; this widget
/// only owns its own header + tabs + content area.
class UndanganScreen extends ConsumerStatefulWidget {
const UndanganScreen({super.key});
@override
ConsumerState<UndanganScreen> createState() => _UndanganScreenState();
}
class _UndanganScreenState extends ConsumerState<UndanganScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
// Use `ref.read` (not watch) — initState runs once, we don't want a rebuild.
// Home tiles set this provider before calling `goBranch(1)` so the tab
// controller lands on the correct sub-tab from the very first frame.
final initialIndex = ref.read(undanganTabProvider);
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: initialIndex,
);
}
@override
void dispose() {
// TabController doesn't touch ref — safe to dispose here.
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// If we're already mounted on this screen and a Home tile re-selects a
// sub-tab, animate to it. (Initial position is handled by initState.)
ref.listen<int>(undanganTabProvider, (prev, next) {
if (next != _tabController.index) {
_tabController.animateTo(next);
}
});
// Watch so the list rebuilds on every state change (new request, accept,
// decline, queue advance). The actual list is read via the notifier getter
// so we don't tie the rebuild to one specific state subtype.
ref.watch(chatRequestProvider);
final invites = ref.read(chatRequestProvider.notifier).pendingInvites;
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Column(
children: [
const _UndanganHeader(),
_UndanganTabBar(
controller: _tabController,
newCount: invites.length,
extendCount: 0,
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_CurhatBaruTab(invites: invites),
const _PerpanjangTab(),
],
),
),
],
),
),
);
}
}
// ─── Header ────────────────────────────────────────────────────────────────
class _UndanganHeader extends StatelessWidget {
const _UndanganHeader();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Undangan',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 20,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
),
);
}
}
// ─── Tab bar ───────────────────────────────────────────────────────────────
class _UndanganTabBar extends StatelessWidget {
final TabController controller;
final int newCount;
final int extendCount;
const _UndanganTabBar({
required this.controller,
required this.newCount,
required this.extendCount,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: HaloTokens.border, width: 1),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: TabBar(
controller: controller,
labelColor: HaloTokens.brand,
unselectedLabelColor: HaloTokens.inkSoft,
indicatorColor: HaloTokens.brand,
indicatorWeight: 2,
labelStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
fontWeight: FontWeight.w700,
),
unselectedLabelStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
fontWeight: FontWeight.w500,
),
tabs: [
_TabLabel(label: 'Curhat Baru', count: newCount),
_TabLabel(
label: 'Perpanjang Curhat',
count: extendCount,
accent: HaloTokens.accentAmber,
),
],
),
);
}
}
class _TabLabel extends StatelessWidget {
final String label;
final int count;
final Color? accent;
const _TabLabel({required this.label, required this.count, this.accent});
@override
Widget build(BuildContext context) {
return Tab(
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(label),
if (count > 0) ...[
const SizedBox(width: 6),
Container(
width: 16,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent ?? const Color(0xFFFF4D6A),
shape: BoxShape.circle,
),
child: Text(
count > 9 ? '9+' : '$count',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 9,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
],
),
);
}
}
// ─── Curhat Baru tab ──────────────────────────────────────────────────────
class _CurhatBaruTab extends ConsumerWidget {
final List<PendingInvite> invites;
const _CurhatBaruTab({required this.invites});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (invites.isEmpty) {
return const _EmptyState(
message: 'Belum ada undangan masuk 💛',
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 20),
itemCount: invites.length,
itemBuilder: (_, i) {
final invite = invites[i];
return Padding(
padding: EdgeInsets.only(bottom: i == invites.length - 1 ? 0 : 12),
child: _InviteCard(
invite: invite,
variant: _InviteCardVariant.curhatBaru,
onAccept: () => _accept(context, invite),
onReject: () => _reject(ref, invite),
),
);
},
);
}
Future<void> _accept(
BuildContext context,
PendingInvite invite,
) async {
// Call the same notifier method as the popup overlay's Terima button.
// Navigation to `/chat/session/:id` is handled by `ChatRequestOverlay`
// (mounted at the app root in `main.dart`) when the state transitions
// to `ChatRequestAcceptedData` — so we deliberately do NOT push the route
// here. If we did, the overlay's `ref.listen` would push it again.
//
// Capture the container before any await so a widget rebuild between the
// Accepting + Accepted states can't invalidate our ref.
final container = ProviderScope.containerOf(context, listen: false);
await container
.read(chatRequestProvider.notifier)
.accept(invite.sessionId);
}
void _reject(WidgetRef ref, PendingInvite invite) {
// `decline` posts to the backend then advances the internal queue —
// identical to the popup-overlay reject button.
ref.read(chatRequestProvider.notifier).decline(invite.sessionId);
}
}
// ─── Perpanjang tab (placeholder) ─────────────────────────────────────────
class _PerpanjangTab extends StatelessWidget {
const _PerpanjangTab();
@override
Widget build(BuildContext context) {
// TODO(stage-3): wire to a pendingExtensionsProvider once backend exposes
// a queryable list of pending extension invitations.
return Container(
color: HaloTokens.accentAmberBg,
child: const _EmptyState(
message: 'Belum ada permintaan perpanjangan 💛',
),
);
}
}
// ─── Empty state ──────────────────────────────────────────────────────────
class _EmptyState extends StatelessWidget {
final String message;
const _EmptyState({required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
),
);
}
}
// ─── Invite card ──────────────────────────────────────────────────────────
enum _InviteCardVariant { curhatBaru, perpanjang }
class _InviteCard extends StatelessWidget {
final PendingInvite invite;
final _InviteCardVariant variant;
final VoidCallback onAccept;
final VoidCallback onReject;
const _InviteCard({
required this.invite,
required this.variant,
required this.onAccept,
required this.onReject,
});
@override
Widget build(BuildContext context) {
final isExtend = variant == _InviteCardVariant.perpanjang;
final borderColor =
isExtend ? const Color(0xFFF5C97A) : HaloTokens.brandSoft;
final bg = isExtend ? const Color(0xFFFFF8EB) : HaloTokens.brandSofter;
// Stable orb color from the session id so repeat visits look consistent.
final orbSeed = invite.sessionId.hashCode;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: bg,
borderRadius: HaloRadius.lg,
border: Border.all(color: borderColor, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
HaloOrb(size: 40, seed: orbSeed),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(
_displayName(invite),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
),
const SizedBox(width: 8),
_ModeBadge(invite: invite, isExtend: isExtend),
],
),
const SizedBox(height: 4),
Text(
_expirySubtitle(invite, isExtend),
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.inkSoft,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 1,
child: HaloButton(
label: 'Tolak',
onPressed: onReject,
variant: HaloButtonVariant.soft,
size: HaloButtonSize.sm,
fullWidth: true,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: isExtend
? _PrimaryAmberButton(
label: 'Terima Perpanjangan →',
onPressed: onAccept,
)
: HaloButton(
label: 'Terima →',
onPressed: onAccept,
variant: HaloButtonVariant.primary,
size: HaloButtonSize.sm,
fullWidth: true,
),
),
],
),
],
),
);
}
String _displayName(PendingInvite invite) {
// The chat-request notifier doesn't carry the customer display_name; the
// popup shows generic copy too ("Ada permintaan chat baru!"). Until that
// payload is enriched on the backend, fall back to a short, neutral
// placeholder rather than leaking the session id.
return 'Customer';
}
String _expirySubtitle(PendingInvite invite, bool isExtend) {
final duration = invite.durationMinutes;
if (isExtend) {
// Per v5.jsx: "klien lama · expired <HH:mm> · tersisa ~N mnt"
return duration != null ? 'klien lama · +$duration menit' : 'klien lama';
}
if (invite.isFreeTrial == true) return 'Free Trial';
return duration != null ? 'Durasi: $duration menit' : '';
}
}
class _ModeBadge extends StatelessWidget {
final PendingInvite invite;
final bool isExtend;
const _ModeBadge({required this.invite, required this.isExtend});
@override
Widget build(BuildContext context) {
// The chat-request payload doesn't carry SessionMode today — popup shows
// generic copy. Default to chat (💬). The extend variant shows "+N mnt".
final showAddMins = isExtend && invite.durationMinutes != null;
final label = showAddMins ? '+${invite.durationMinutes} mnt' : '💬 Chat';
final bg =
isExtend ? HaloTokens.accentAmberSoft : HaloTokens.surface;
final fg = isExtend ? const Color(0xFF7A4A0E) : HaloTokens.brandDark;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: HaloRadius.pill,
),
child: Text(
label,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
fontWeight: isExtend ? FontWeight.w700 : FontWeight.w600,
color: fg,
),
),
);
}
}
/// Amber-tinted primary button used for "Terima Perpanjangan". HaloButton
/// hard-codes the brand pink for `primary`, so we render an inline
/// ElevatedButton that matches the variant's geometry but with the amber
/// accent from the Figma extend palette.
class _PrimaryAmberButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
const _PrimaryAmberButton({required this.label, required this.onPressed});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.accentAmber,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
),
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
child: Text(label),
),
);
}
}

View File

@@ -7,12 +7,15 @@ import 'features/auth/screens/account_inactive_screen.dart';
import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart';
import 'features/profile/profil_screen.dart';
import 'features/shell/shell_screen.dart';
import 'features/chat/screens/active_sessions_screen.dart';
import 'features/chat/screens/mitra_chat_screen.dart';
import 'features/chat/screens/chat_history_screen.dart';
import 'features/chat/screens/chat_transcript_screen.dart';
import 'features/chat/screens/request_history_screen.dart';
import 'features/chat/screens/request_history_detail_screen.dart';
import 'features/undangan/undangan_screen.dart';
class RouterNotifier extends ChangeNotifier {
final Ref _ref;
@@ -59,23 +62,38 @@ GoRouter buildRouter(Ref ref) {
return null;
},
routes: [
// ── Standalone routes (no tab bar) ───────────────────────────────────
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(
path: '/otp',
builder: (context, state) => OtpScreen(phone: state.extra as String),
),
GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
// Full-screen chat session / transcript / history routes — these live
// outside the shell because BestieChatV5 is full-screen in the figma
// (the tab bar is hidden during an active session or transcript view).
GoRoute(path: '/sessions', builder: (_, __) => const ActiveSessionsScreen()),
GoRoute(path: '/chat/session/:sessionId', builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return MitraChatScreen(
sessionId: state.pathParameters['sessionId']!,
customerName: extra?['customerName'] as String? ?? 'Customer',
);
}),
GoRoute(path: '/chat/history', builder: (_, __) => const MitraChatHistoryScreen()),
GoRoute(path: '/chat/history/:sessionId', builder: (context, state) {
return MitraChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!);
}),
GoRoute(
path: '/chat/session/:sessionId',
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>?;
return MitraChatScreen(
sessionId: state.pathParameters['sessionId']!,
customerName: extra?['customerName'] as String? ?? 'Customer',
);
},
),
GoRoute(
path: '/chat/history',
builder: (_, __) => const MitraChatHistoryScreen(),
),
GoRoute(
path: '/chat/history/:sessionId',
builder: (context, state) =>
MitraChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!),
),
GoRoute(
path: '/chat/requests/history',
builder: (_, __) => const RequestHistoryScreen(),
@@ -86,6 +104,38 @@ GoRouter buildRouter(Ref ref) {
notificationId: state.pathParameters['notificationId']!,
),
),
// ── Tab-shell routes (3 branches behind a persistent BestieTabBar) ──
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
ShellScreen(navigationShell: navigationShell),
branches: [
// Branch 0 — Home
StatefulShellBranch(
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
],
),
// Branch 1 — Chat (Undangan: Curhat Baru + Perpanjang Curhat tabs)
StatefulShellBranch(
routes: [
GoRoute(
path: '/chat',
builder: (_, __) => const UndanganScreen(),
),
],
),
// Branch 2 — Profil (BestieProfile, Stage 4)
StatefulShellBranch(
routes: [
GoRoute(
path: '/profil',
builder: (_, __) => const ProfilScreen(),
),
],
),
],
),
],
);
}