Mitra chat: real customer name + ticking timer + goodbye pill
Four small fixes on the mitra chat screen, all surfacing through the
chat connected-state.
1. AppBar customer name. The hardcoded "Customer" only ever came from
the FCM-tap navigation fallback (notification_service:
`extra: {'customerName': 'Customer'}`); the popup-overlay path passes
the real name but FCM had no way to know it. /chat/:sessionId/info
already returns `customer_display_name` — propagate it into
MitraChatConnectedData and read in the AppBar via .select. Falls
back to the route arg for the brief connecting window.
2. SISA WAKTU stuck at --:--. The pill watches a remaining-seconds
provider that's only updated by backend WS frames. Backend only
fires session_timer at 3-min + 1-min warnings + expiry, so the pill
sat at --:-- for the first ~7 minutes of a 10-min chat. Added a
local 1s ticker in the notifier that drives the provider against
expires_at (also pulled from /info). WS warning frames still
overwrite normally on top.
3. Pesan Penutup textbox. Replaced the rounded-rect OutlineInputBorder
field with a fixed-height Container pill whose border matches the
previous "shadow" tone (HaloTokens.border). Pill borderRadius is
the full 100 (was 12).
4. Goodbye textbox text was top-aligned because maxLines: 3 +
OutlineInputBorder left vertical alignment to InputDecoration's
built-in padding. Switched to maxLines: 1 + textAlignVertical:
center + isCollapsed: true inside the fixed-height container —
text now sits on the vertical center.
Bonus: the goodbye subhead "Tuliskan pesan terakhirmu untuk Customer"
also picked up the real name ("…untuk Andi Pratama").
Verified end-to-end on emulator-5556 (TestMitra-1501 + customer
"Andi Pratama"): AppBar shows Andi Pratama, SISA WAKTU ticks (04:57 →
04:35 across screenshots), goodbye pill renders with centered hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,12 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
final List<String> topics;
|
final List<String> topics;
|
||||||
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
|
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
|
||||||
final SessionMode mode;
|
final SessionMode mode;
|
||||||
|
// Customer display name + session deadline pulled from /info on connect.
|
||||||
|
// AppBar reads displayName (falls back to route-arg "Customer" when null);
|
||||||
|
// the timer pill ticks down against expiresAt via a local 1s ticker so it
|
||||||
|
// doesn't sit at --:-- waiting for a server warning frame.
|
||||||
|
final String? customerDisplayName;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
const MitraChatConnectedData({
|
const MitraChatConnectedData({
|
||||||
required this.messages,
|
required this.messages,
|
||||||
@@ -73,6 +79,8 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
this.topicSensitivity = TopicSensitivity.regular,
|
this.topicSensitivity = TopicSensitivity.regular,
|
||||||
this.topics = const [],
|
this.topics = const [],
|
||||||
this.mode = SessionMode.chat,
|
this.mode = SessionMode.chat,
|
||||||
|
this.customerDisplayName,
|
||||||
|
this.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
MitraChatConnectedData copyWith({
|
MitraChatConnectedData copyWith({
|
||||||
@@ -87,6 +95,8 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
TopicSensitivity? topicSensitivity,
|
TopicSensitivity? topicSensitivity,
|
||||||
List<String>? topics,
|
List<String>? topics,
|
||||||
SessionMode? mode,
|
SessionMode? mode,
|
||||||
|
String? customerDisplayName,
|
||||||
|
DateTime? expiresAt,
|
||||||
}) {
|
}) {
|
||||||
return MitraChatConnectedData(
|
return MitraChatConnectedData(
|
||||||
messages: messages ?? this.messages,
|
messages: messages ?? this.messages,
|
||||||
@@ -99,6 +109,8 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
|
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
|
||||||
topics: topics ?? this.topics,
|
topics: topics ?? this.topics,
|
||||||
mode: mode ?? this.mode,
|
mode: mode ?? this.mode,
|
||||||
|
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +155,11 @@ class MitraChat extends _$MitraChat {
|
|||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
StreamSubscription? _wsSubscription;
|
StreamSubscription? _wsSubscription;
|
||||||
Timer? _typingTimer;
|
Timer? _typingTimer;
|
||||||
|
// Local 1s ticker that updates mitraChatRemainingSecondsProvider against
|
||||||
|
// the session's expires_at. Backend's session_timer WS frames only fire
|
||||||
|
// at 3min + 1min warnings + expiry — without this ticker the pill stays
|
||||||
|
// at --:-- for most of the session.
|
||||||
|
Timer? _countdownTimer;
|
||||||
// Survives `disconnect()` so a later `didChangeAppLifecycleState(resumed)`
|
// Survives `disconnect()` so a later `didChangeAppLifecycleState(resumed)`
|
||||||
// can re-issue `connect(sessionId)` with the right session — disconnect()
|
// can re-issue `connect(sessionId)` with the right session — disconnect()
|
||||||
// resets `state` to MitraChatInitialData, which is otherwise the only
|
// resets `state` to MitraChatInitialData, which is otherwise the only
|
||||||
@@ -178,6 +195,9 @@ class MitraChat extends _$MitraChat {
|
|||||||
final espTopics = rawTopics is List
|
final espTopics = rawTopics is List
|
||||||
? rawTopics.whereType<String>().toList(growable: false)
|
? rawTopics.whereType<String>().toList(growable: false)
|
||||||
: const <String>[];
|
: const <String>[];
|
||||||
|
final customerDisplayName = sessionData?['customer_display_name'] as String?;
|
||||||
|
final expiresAtRaw = sessionData?['expires_at'] as String?;
|
||||||
|
final expiresAt = expiresAtRaw == null ? null : DateTime.tryParse(expiresAtRaw);
|
||||||
|
|
||||||
// If the customer requested an extension while we were off-WS (e.g.
|
// If the customer requested an extension while we were off-WS (e.g.
|
||||||
// mitra on Home, app backgrounded, FCM tap cold-started us here),
|
// mitra on Home, app backgrounded, FCM tap cold-started us here),
|
||||||
@@ -230,7 +250,13 @@ class MitraChat extends _$MitraChat {
|
|||||||
topics: espTopics,
|
topics: espTopics,
|
||||||
mode: sessionMode,
|
mode: sessionMode,
|
||||||
extensionRequest: pendingExt,
|
extensionRequest: pendingExt,
|
||||||
|
customerDisplayName: customerDisplayName,
|
||||||
|
expiresAt: expiresAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Kick off the 1s ticker if we have a deadline. Updates the auto-dispose
|
||||||
|
// remaining-seconds provider used by the timer pill.
|
||||||
|
if (expiresAt != null) _startCountdownTicker(expiresAt);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const MitraChatErrorData('Gagal terhubung ke chat.');
|
state = const MitraChatErrorData('Gagal terhubung ke chat.');
|
||||||
}
|
}
|
||||||
@@ -413,5 +439,23 @@ class MitraChat extends _$MitraChat {
|
|||||||
_channel = null;
|
_channel = null;
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_typingTimer = null;
|
_typingTimer = null;
|
||||||
|
_countdownTimer?.cancel();
|
||||||
|
_countdownTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the remaining-seconds provider every second against [deadline].
|
||||||
|
/// Backend session_timer WS frames still arrive (3min/1min warnings,
|
||||||
|
/// expiry) — they overwrite via the existing _onMessageReceived handler.
|
||||||
|
/// On extension accept the connect() flow re-runs (sessionData refreshes)
|
||||||
|
/// and the new deadline restarts the ticker.
|
||||||
|
void _startCountdownTicker(DateTime deadline) {
|
||||||
|
_countdownTimer?.cancel();
|
||||||
|
void tick() {
|
||||||
|
final remaining = deadline.difference(DateTime.now()).inSeconds;
|
||||||
|
final clamped = remaining < 0 ? 0 : remaining;
|
||||||
|
ref.read(mitraChatRemainingSecondsProvider.notifier).update(clamped);
|
||||||
|
}
|
||||||
|
tick(); // initial paint — don't wait a second for the first value
|
||||||
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) => tick());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,16 +147,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_MitraChatTitleName(fallback: widget.customerName),
|
||||||
widget.customerName,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: HaloTokens.ink,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const _MitraChatSubtitle(),
|
const _MitraChatSubtitle(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -186,6 +177,35 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
/// rebuilds this widget — only an actual mode flip or expiry transition does.
|
/// rebuilds this widget — only an actual mode flip or expiry transition does.
|
||||||
/// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:239): "sesi aktif · Chat",
|
/// Mirrors `BestieChatV5` (figma-bestie/.../v5.jsx:239): "sesi aktif · Chat",
|
||||||
/// "sesi aktif · Voice", or "sesi berakhir" (color #A8410E when ended).
|
/// "sesi aktif · Voice", or "sesi berakhir" (color #A8410E when ended).
|
||||||
|
/// AppBar customer-name line. Reads customerDisplayName from the chat
|
||||||
|
/// connected-state via `.select` so the parent AppBar doesn't rebuild on
|
||||||
|
/// every chat-state change. Falls back to the route-arg `fallback` value
|
||||||
|
/// (which is "Customer" when reached via FCM tap — see notification_service
|
||||||
|
/// `_navigateFromMessage`).
|
||||||
|
class _MitraChatTitleName extends ConsumerWidget {
|
||||||
|
final String fallback;
|
||||||
|
const _MitraChatTitleName({required this.fallback});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final name = ref.watch(mitraChatProvider.select((s) {
|
||||||
|
if (s is MitraChatConnectedData) return s.customerDisplayName;
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
final displayed = (name != null && name.isNotEmpty) ? name : fallback;
|
||||||
|
return Text(
|
||||||
|
displayed,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: HaloTokens.ink,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MitraChatSubtitle extends ConsumerWidget {
|
class _MitraChatSubtitle extends ConsumerWidget {
|
||||||
const _MitraChatSubtitle();
|
const _MitraChatSubtitle();
|
||||||
|
|
||||||
@@ -426,7 +446,7 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
|
|||||||
final showGoodbye = !state.goodbyeSubmitted &&
|
final showGoodbye = !state.goodbyeSubmitted &&
|
||||||
(state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData);
|
(state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData);
|
||||||
if (showGoodbye) {
|
if (showGoodbye) {
|
||||||
return _buildGoodbyeView(extState);
|
return _buildGoodbyeView(extState, state.customerDisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.sessionClosing && state.goodbyeSubmitted) {
|
if (state.sessionClosing && state.goodbyeSubmitted) {
|
||||||
@@ -851,7 +871,10 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGoodbyeView(ExtensionData extState) {
|
Widget _buildGoodbyeView(ExtensionData extState, String? customerDisplayName) {
|
||||||
|
final name = (customerDisplayName != null && customerDisplayName.isNotEmpty)
|
||||||
|
? customerDisplayName
|
||||||
|
: widget.customerName;
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -861,14 +884,33 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
|
Text('Tuliskan pesan terakhirmu untuk $name', textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextField(
|
// Pill-shaped, single-line text field with the border color picked
|
||||||
|
// up from the previous "shadow" tone (HaloTokens.border). Container
|
||||||
|
// fixes the height so textAlignVertical can center the entry.
|
||||||
|
Container(
|
||||||
|
height: 56,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: HaloTokens.surface,
|
||||||
|
borderRadius: BorderRadius.circular(100),
|
||||||
|
border: Border.all(color: HaloTokens.border, width: 1),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
controller: widget.goodbyeController,
|
controller: widget.goodbyeController,
|
||||||
maxLines: 3,
|
maxLines: 1,
|
||||||
decoration: InputDecoration(
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
style: const TextStyle(fontSize: 14, color: HaloTokens.ink),
|
||||||
|
decoration: const InputDecoration(
|
||||||
hintText: 'Terima kasih sudah curhat...',
|
hintText: 'Terima kasih sudah curhat...',
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
hintStyle: TextStyle(color: HaloTokens.inkMuted, fontSize: 14),
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
disabledBorder: InputBorder.none,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
Reference in New Issue
Block a user