diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index 8cf5e7d..989db26 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -61,6 +61,12 @@ class MitraChatConnectedData extends MitraChatData { final List topics; // Phase 4 — voice-call mode badge in header. `chat` is the default (no pill). 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({ required this.messages, @@ -73,6 +79,8 @@ class MitraChatConnectedData extends MitraChatData { this.topicSensitivity = TopicSensitivity.regular, this.topics = const [], this.mode = SessionMode.chat, + this.customerDisplayName, + this.expiresAt, }); MitraChatConnectedData copyWith({ @@ -87,6 +95,8 @@ class MitraChatConnectedData extends MitraChatData { TopicSensitivity? topicSensitivity, List? topics, SessionMode? mode, + String? customerDisplayName, + DateTime? expiresAt, }) { return MitraChatConnectedData( messages: messages ?? this.messages, @@ -99,6 +109,8 @@ class MitraChatConnectedData extends MitraChatData { topicSensitivity: topicSensitivity ?? this.topicSensitivity, topics: topics ?? this.topics, mode: mode ?? this.mode, + customerDisplayName: customerDisplayName ?? this.customerDisplayName, + expiresAt: expiresAt ?? this.expiresAt, ); } } @@ -143,6 +155,11 @@ class MitraChat extends _$MitraChat { WebSocketChannel? _channel; StreamSubscription? _wsSubscription; 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)` // can re-issue `connect(sessionId)` with the right session — disconnect() // resets `state` to MitraChatInitialData, which is otherwise the only @@ -178,6 +195,9 @@ class MitraChat extends _$MitraChat { final espTopics = rawTopics is List ? rawTopics.whereType().toList(growable: false) : const []; + 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. // mitra on Home, app backgrounded, FCM tap cold-started us here), @@ -230,7 +250,13 @@ class MitraChat extends _$MitraChat { topics: espTopics, mode: sessionMode, 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) { state = const MitraChatErrorData('Gagal terhubung ke chat.'); } @@ -413,5 +439,23 @@ class MitraChat extends _$MitraChat { _channel = null; _typingTimer?.cancel(); _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()); } } diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index adb259e..f73ca27 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -147,16 +147,7 @@ class _MitraChatScreenState extends ConsumerState { 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, - ), - ), + _MitraChatTitleName(fallback: widget.customerName), const _MitraChatSubtitle(), ], ), @@ -186,6 +177,35 @@ class _MitraChatScreenState extends ConsumerState { /// 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). +/// 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 { const _MitraChatSubtitle(); @@ -426,7 +446,7 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> { final showGoodbye = !state.goodbyeSubmitted && (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData); if (showGoodbye) { - return _buildGoodbyeView(extState); + return _buildGoodbyeView(extState, state.customerDisplayName); } 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( padding: const EdgeInsets.all(32), child: Column( @@ -861,14 +884,33 @@ class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> { const SizedBox(height: 16), const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 8), - const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center), + Text('Tuliskan pesan terakhirmu untuk $name', textAlign: TextAlign.center), const SizedBox(height: 24), - TextField( - controller: widget.goodbyeController, - maxLines: 3, - decoration: InputDecoration( - hintText: 'Terima kasih sudah curhat...', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + // 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, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(fontSize: 14, color: HaloTokens.ink), + decoration: const InputDecoration( + hintText: 'Terima kasih sudah curhat...', + 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),