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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
430
mitra_app/lib/features/profile/profil_screen.dart
Normal file
430
mitra_app/lib/features/profile/profil_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
mitra_app/lib/features/shell/shell_screen.dart
Normal file
44
mitra_app/lib/features/shell/shell_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
146
mitra_app/lib/features/shell/widgets/bestie_tab_bar.dart
Normal file
146
mitra_app/lib/features/shell/widgets/bestie_tab_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
528
mitra_app/lib/features/undangan/undangan_screen.dart
Normal file
528
mitra_app/lib/features/undangan/undangan_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user